[AoNW] Bringing a Flutter Game to the Web

Written by

in

, ,

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 --> API

The 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 --> IndexedDB

The 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 --> API

The 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 saveLayer usage 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 --> HUD

A 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 bundle

The 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 --> UX

None 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.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *