When I started building Age of New Worlds, I was thinking mostly about the native app.
Flutter gave me the UI layer. Flame gave me the game scene. Dart gave me a shared language for the client, the rules, the AI, and the server. That was already enough complexity for a solo project.
Then I wanted one more thing: a public web version.
Not because the browser is the best place to play a heavy 4X strategy game. It is not. The native app is still the better experience. But the web version solves a very practical problem: people can open a link, see the game, start a match, and give feedback without installing anything.
That matters a lot for an early public test.
This post is about what I had to change to make that possible: persistence, build targets, API configuration, WebAssembly, performance work, and the compromises that come with putting a Flutter/Flame strategy game inside a browser.
The Goal
The first goal was not “make the perfect web game.”
The goal was smaller and more useful:
- the same single-player flow should run in a browser,
- saves should work without local files,
- the game should talk to the production API when needed,
- the web bundle should be deployable as static files,
- performance should be good enough for testing core mechanics,
- the native app should remain the main target.
That last point is important.
I did not want to redesign the whole game around the browser. I wanted a web build that uses the same architecture, shares the same rules, and exposes the same gameplay, while accepting that it will have different limits.
The target architecture became:
flowchart LR
Browser["Browser"]
Flutter["Flutter Web"]
Flame["Flame Renderer"]
Riverpod["Riverpod State"]
Core["Shared Dart Core"]
Storage["IndexedDB / sembast_web"]
API["api.aonw.net"]
Browser --> Flutter
Flutter --> Flame
Flutter --> Riverpod
Riverpod --> Core
Riverpod --> Storage
Riverpod --> APIThe interesting part is not that Flutter can compile to web. The interesting part is all the surrounding assumptions that stop being true once the app runs in a browser.
The First Problem: Persistence
On desktop and mobile, local saves can use ordinary app storage. On web, there is no normal filesystem path I can casually write to.
The game already had a repository boundary for saves, so I did not need to rewrite gameplay. I needed a web implementation behind the same interface.
That became a small sembast_web layer over IndexedDB.
Conceptually:
flowchart TB
Game["Game UI / Use Cases"]
Port["GameRepository Port"]
NativeRepo["Native Repository"]
WebRepo["WebGameRepository"]
Snapshot["WebSnapshotStore"]
IndexedDB[("IndexedDB")]
Game --> Port
Port --> NativeRepo
Port --> WebRepo
WebRepo --> Snapshot
Snapshot --> IndexedDBThe web repository stores two kinds of data:
- a save index, so the load-game screen can list available saves,
- the latest snapshot, so a match can be restored quickly.
I like this boundary because it keeps the browser-specific detail out of the game rules. The domain does not know that IndexedDB exists. The UI does not need to manually serialize game state. The infrastructure layer is the only place that cares about sembast_web.
That is the kind of boring architecture decision that pays off later.
The save system is still not something I would call rock solid. This is an early build, and I am very explicit that progress may be lost. But without the repository boundary, getting web saves working would have been much uglier.
The Second Problem: API URLs
Native builds and web builds need to point at the same production API, but they reach it through different deployment flows.
I did not want the API host hardcoded into random widgets. The app already resolves API configuration through a small config object, and the build injects the URL with a Dart define:
flutter build web --wasm --release \
--dart-define=AONW_API_BASE_URL=https://api.aonw.net
That value is baked into the generated web bundle.
The client can then derive WebSocket URLs from the HTTP base URL:
https://api.aonw.net -> wss://api.aonw.net
http://localhost:8080 -> ws://localhost:8080
That sounds tiny, but it removed a lot of release anxiety. I can build local, staging, web, and iOS archives with the same pattern:
build target + AONW_API_BASE_URL -> correct backend
This matters even more once the web app is no longer served from the same host as the API.
The current split is:
flowchart LR
Home["aonw.net\nstatic homepage"]
Demo["demo.aonw.net\nFlutter web app"]
API["api.aonw.net\nDart server"]
Home --> Demo
Demo --> APIThe homepage is intentionally small and static. The demo host serves the Flutter web bundle. The API host runs the Dart backend.
That separation made the deployment clearer.
WebAssembly, JavaScript Fallback, and Reality
The web build uses:
flutter build web --wasm --release
This produces a WebAssembly build with a JavaScript fallback. That is useful because Flutter’s web story is moving toward WASM, but I still want the deployed bundle to work in browsers that do not take the fastest path.
The important lesson is that WASM is not a magic “make game fast” button.
It helps, and it is the direction I want the web build to move in, but AONW is still a strategy game with:
- a large hex map,
- fog of war,
- many overlay layers,
- sprites and icons,
- UI panels,
- AI turns,
- save serialization,
- camera movement,
- route previews,
- combat previews,
- and a lot of small state updates.
The browser has to deal with all of that inside a tab.
The native app still wins on performance and consistency.
So the web version is experimental by design. It is good enough to test mechanics. It is not yet the best way to play a long match.
Performance Work Is Mostly Removing Accidental Cost
The first performance pass was not glamorous.
It was mostly about asking a simple question again and again:
What is the browser doing that it does not need to do?
In a 4X game, the map can easily become the most expensive part of the screen. It is tempting to solve this with one big optimization trick, but most of the work is smaller:
- avoid rebuilding UI panels that did not change,
- keep rendering layers separated,
- cache repeated icons and images,
- avoid expensive paint operations when a simpler draw is enough,
- avoid unnecessary
saveLayerusage in canvas paths, - keep action hints and help popups out of the hot rendering path,
- throttle or simplify AI work where the runtime profile needs it,
- hide developer-only tools from the web build,
- keep map overlays predictable.
The renderer is also split into layers because not every visual element has the same lifetime.
flowchart TB
State["Game State"]
Camera["Camera"]
Terrain["Terrain Layer"]
Fog["Fog Layer"]
Units["Unit Layer"]
Intent["Intent / Preview Layer"]
HUD["Flutter HUD"]
State --> Terrain
State --> Fog
State --> Units
State --> Intent
Camera --> Terrain
Camera --> Fog
Camera --> Units
Camera --> Intent
State --> HUDA fog overlay does not have the same update rhythm as a hover preview. A unit marker does not have the same lifecycle as a static terrain image. If everything is treated as “the map,” every change becomes more expensive than it needs to be.
The other important piece is expectations.
I am not trying to claim that the web build is as fast as the app. It is not. The web build exists so that testers can get into the game quickly, report confusing flows, and test early mechanics.
Performance still matters, but the performance target is different.
For now, the question is:
Can someone open the web version, play enough turns to understand the game loop, and give useful feedback?
That is a realistic target.
The UI Had to Become More Web-Friendly
The browser also changes how UI mistakes feel.
On native, some rough edges are easier to forgive. On web, people expect things to be immediately legible. They are one tab close away from leaving.
That pushed me to improve:
- responsive layout,
- main menu behavior,
- help popups,
- tutorial access,
- map controls,
- manual/control documentation,
- homepage links,
- and the difference between demo, app, and social links.
One small example is the help system. In a strategy game, hints cannot only be contextual. If a player skips the tutorial or loses the current hint, there still needs to be a reliable place to find it again.
That led to a more consistent rule:
? menu -> every minimized hint and tutorial card
Not “only what is relevant to the current selection.”
That sounds like a UX detail, but for a public web build it matters. People will test quickly. They will not read everything. They will click around in a different order than I expect.
The interface has to survive that.
The Build Output Is Just Static Files
One thing I like about Flutter web is that, after the build, the output is boring:
build/web/
├── index.html
├── flutter_bootstrap.js
├── main.dart.js
├── main.dart.mjs
├── main.dart.wasm
├── flutter_service_worker.js
├── assets/
└── version.json
That is exactly what I wanted for deployment.
The web app does not need a Node server. It does not need a special runtime process. It can sit behind Caddy as static files and call the Dart API separately.
The deployment shape is:
sequenceDiagram
participant Dev as Developer Machine
participant Flutter as Flutter Build
participant VPS as VPS Filesystem
participant Caddy as Caddy
participant User as Browser
Dev->>Flutter: flutter build web --wasm --release
Flutter-->>Dev: build/web
Dev->>VPS: rsync build/web -> build/demo
User->>Caddy: GET https://demo.aonw.net
Caddy->>VPS: read /srv/demo
Caddy-->>User: static Flutter bundleThe nice part is that a web deployment does not restart the backend. Caddy has a read-only bind mount to the directory. When I overwrite the files, the next request sees the new bundle.
That is simple, and simple is good here.
Things That Were More Annoying Than Expected
The first annoyance was browser storage.
Not because IndexedDB is impossible, but because save stability suddenly depends on browser behavior, service workers, cache state, and development builds. A native app has its own storage expectations. A browser tab has different ones.
The second annoyance was service worker stickiness.
For a public test, I want the newest build to load quickly after deploy. Flutter’s service worker is useful, but it can make old shells hang around for one reload longer than expected. That is why the Caddy config treats HTML and service worker files differently from hashed assets.
The rule is:
index.html / service worker -> no-cache
hashed assets -> long immutable cache
The third annoyance was performance debugging.
A game can be “fine” for five turns and then feel worse after the map gets busier. That is much harder to judge from a smoke test. I need real testers playing weirdly, opening panels, dragging the camera, ending turns, and doing things I would not do in my own happy path.
That is one reason I wanted the web build public early.
What I Would Improve Next
The web build works, but there are obvious next steps:
- measure frame time on larger maps,
- make the renderer more aggressive about skipping unchanged layers,
- continue reducing expensive canvas operations,
- add clearer in-game warnings when web performance is degraded,
- improve save recovery,
- make service worker updates more visible,
- keep testing WASM behavior across browsers,
- and keep the native app as the primary performance baseline.
I also want to make web smoke testing part of the release routine, not something I do manually only when I remember.
The current release already checks that https://demo.aonw.net/ returns 200. That proves Caddy is serving something. It does not prove that a 100-turn game feels good.
Those are different tests.
The Lesson
Bringing AONW to the web was not one feature.
It was a chain of small architecture decisions:
flowchart LR
Persistence["Web persistence"]
Config["API config"]
Build["WASM build"]
Hosting["Static hosting"]
Cache["Cache headers"]
Performance["Renderer performance"]
UX["Web-friendly UX"]
Persistence --> Config
Config --> Build
Build --> Hosting
Hosting --> Cache
Cache --> Performance
Performance --> UXNone of those pieces is especially heroic alone.
Together, they turned the game from “something that runs on my machine” into something I can send to a stranger with a link.
That is a big step for a solo game project.
The web version is slower than the app. It is experimental. It will probably break in embarrassing ways.
But it also makes feedback easier, and feedback is what the project needs now.
At this stage, that tradeoff is worth it.
Leave a Reply