DEV Community

Ernest
Ernest

Posted on

Inside the Architecture 4x Strategy Game: One Core for Local Play, AI and Multiplayer

Age of New Worlds is an open-source, hex-based 4X strategy game built with Flutter, Flame, Dart, and Serverpod.

You can explore the full system in the interactive Architecture Atlas

As the project gained AI opponents, save/load, replay, simultaneous turns, and online multiplayer, the main architectural problem was no longer where to place another class. It was deciding which part of the system actually owns the rules.

Local play, simulations, and the server already shared parts of the same logic, but separate orchestration paths and state representations still made semantic drift possible. A move could eventually behave differently locally and online. A replay could resolve a turn differently from the server. The client could try to reconstruct an animation from state that never contained the complete authoritative path.

The current refactor is built around one constraint:

Local play, AI, replay, simulations, and multiplayer must all end in the same deterministic game engine.

I recently published an interactive Architecture Atlas that presents both the current code and the accepted target recorded in the project's ADRs. That distinction is important: some boundaries are already complete, while others are still being migrated.

The central rule: one authoritative core

At a high level, the architecture looks like this:

 Flutter UI      AI / MCTS       Replay       Serverpod
     |               |              |              |
     +------- DomainCommand / SystemCommand -------+
                            |
                     aonw_core GameEngine
                            |
             accepted / rejected deterministic result
                    /             |              \
             UI projection    persistence     simulation
Enter fullscreen mode Exit fullscreen mode

The Flutter client and the Serverpod backend are adapters around the same Dart-only core. The server remains authoritative, but authority means:

  • authenticating the actor
  • ordering commands
  • enforcing idempotency
  • persisting snapshots and events
  • projecting recipient-safe views
  • broadcasting accepted results

It does not mean maintaining a second implementation of the game rules.

Repository boundaries

The main repository areas have deliberately different responsibilities:

Area Responsibility
lib/game/ Flutter client, Riverpod state, Flame rendering, application services, local persistence, and adapters
packages/aonw_core/ Dart-only commands, state, deterministic rules, shared protocol models, replay contracts, and AI planning
packages/aonw_server_client/ Generated Serverpod client used by the Flutter application
server/ Authentication, matchmaking, multiplayer orchestration, recipient projection, persistence, and realtime streams
docs/ ADRs, protocol contracts, quality policies, runbooks, and gameplay documentation

The dependency direction is more important than the folder names. Presentation may call application services, and adapters may call the core, but Flutter widgets, Serverpod sessions, database rows, and localized strings must not enter the rules engine.

Not every click is a game command

One of the most useful changes was separating presentation input from authoritative intent.

 tap / click / shortcut
           |
       GameIntent ------------------> InteractionState
           |
           +-- when complete --> DomainCommand ---> GameEngine

 trusted scheduler / server -------> SystemCommand ---> GameEngine
                                                       |
                                                  DomainEvent
Enter fullscreen mode Exit fullscreen mode

The model now distinguishes four concepts:

  • GameIntent describes client interaction such as selecting a unit, focusing a tile, opening a panel, entering targeting mode, or cancelling a preview. It may change client-local InteractionState, but it never enters the multiplayer protocol or authoritative event log.
  • DomainCommand is a complete immutable request to change DomainState. It is the only kind of player-originated command accepted by the engine.
  • SystemCommand represents trusted transitions such as timeout resolution or forced turn finalization. It is not exposed through player command endpoints.
  • DomainEvent records an accepted domain fact. It is output, not another command or an instruction for the UI.

This means that selecting a hex is not serialized as gameplay history. A worker picker can keep its incomplete workflow in the client and emit one complete domain command only after confirmation. UI behavior can change without changing replay or network compatibility.

One engine, different execution adapters

Local and network play take different routes to the same engine.

 LOCAL
 UI -> GameIntentResolver -> LocalCommandResolver
    -> GameEngine.apply(...) -> client projection

 ONLINE
 UI -> GameIntentResolver -> versioned WireCommand
    -> authenticated Serverpod adapter
    -> GameEngine.apply(...)
    -> atomic persistence -> ACK / projected broadcast
Enter fullscreen mode Exit fullscreen mode

The target engine contract is conceptually simple:

apply(
  DomainState,
  DomainCommand | SystemCommand,
  EngineContext
) -> DomainTransition
Enter fullscreen mode Exit fullscreen mode

EngineContext captures every external value that can affect a rule: the immutable WorldMap, resolved ruleset, authoritative actor, tick and turn metadata, the current time only when a rule genuinely depends on it, and deterministic random seed or entropy state.

The engine itself is synchronous and side-effect free. It performs no database, filesystem, network, logging, localization, Flutter, or Serverpod work. Equal state, command, and context should produce an equal result.

The current implementation is close to this boundary, but not yet identical to the final contract. It still accepts a canonical snapshot envelope and returns GameEngineResult, which contains some animation-oriented evidence used by adapters. Narrowing that result to next state plus ordered domain facts is one of the remaining migrations.

One authoritative state, several explicit projections

State ownership follows the same principle.

 MapDraft -- validate + freeze --> immutable WorldMap
                                         |
 DomainCommand + EngineContext --------> GameEngine
                                         |
                                  immutable DomainState'
                                         |
             +---------------------------+------------------------+
             |                           |                        |
 CanonicalGameSnapshot          recipient projection     client composition
 metadata + state + offset       RecipientSnapshot        InteractionState
                                                          RenderState
Enter fullscreen mode Exit fullscreen mode

MapDraft is the only mutable map representation and belongs to the editor. Gameplay receives a validated, immutable WorldMap with indexed hex lookup.

DomainState is the single source of truth for rule-relevant data: turns, participants, economy, units, cities, fog of war, research, diplomacy, objectives, outcomes, and other gameplay systems. Updates return a new value.

Client-only concepts do not belong there. Selection, focus, open panels, targeting previews, camera state, animation state, and rendering caches live in InteractionState or derived RenderState projections.

Persistence uses CanonicalGameSnapshot, which contains metadata, the complete authoritative state, and one applied event offset. Multiplayer clients receive a nominally different RecipientSnapshot, projected for a specific player and potentially missing hidden information. A recipient snapshot is never valid engine input.

That type-level separation is important: a convenient network view should not accidentally become a substitute for canonical state.

Multiplayer: ACK, retry, projection, and recovery

The multiplayer path adds transport concerns without adding another rules engine.

 Client A                  Server                         Client B
    |                         |                              |
    | command(id = 42)        |                              |
    |------------------------>| validate + apply             |
    |                         | persist state/event/offset    |
    |<--------- ACK ----------|                              |
    |                         |------ projected event ------>|
Enter fullscreen mode Exit fullscreen mode

Every command carries a clientMessageId. Retrying the same command with the same ID returns the previously stored result instead of applying a second transition. Reusing the ID with a different payload is rejected as a conflict.

For an accepted player command, the server stores the snapshot, canonical event, and new offset before delivery. The caller receives a direct ACK and is excluded from the corresponding event broadcast, preventing the same local action from being animated twice. Other participants receive recipient-projected events and attached snapshots.

Reconnect is snapshot-first. The latest projected snapshot becomes authoritative before any newer visible event markers are applied. The client does not rebuild missing history by comparing two snapshots.

Movement is also carried as explicit authoritative evidence. Protocol events include ordered movementExecutions with origins, steps, and costs. Clients preserve that order and never run pathfinding to guess what happened. Fog-of-war projection follows a fail-closed whole-chain policy: when a route cannot be proven safe for a recipient, the complete chain for that unit is removed rather than leaking a partial hidden path.

Architecture is executable, not only documented

The repository's main local gate is:

make ci
Enter fullscreen mode Exit fullscreen mode

It combines several checks:

 make ci
   |- generated-code drift
   |- formatting and fatal static analysis
   |- dependency boundaries and repository census
   |- file, type, nesting, cyclomatic, and cognitive budgets
   |- mutation tests for critical behavior
   |- deterministic performance workloads
   |- coverage floors and changed-line coverage
   `- package, contract, and generated-client tests
Enter fullscreen mode Exit fullscreen mode

The architecture budget does not pretend all legacy debt has already disappeared. Existing over-target metrics are recorded at their exact measured value. They may remain stable or decrease, but they cannot grow, move to a new name, or be hidden by refreshing the baseline.

Every Dart source must also belong to one declared repository role. A new file outside the known application, core, server, client, test, tool, or vendored roots fails the gate. This turns the architecture map into an enforceable repository contract rather than a diagram that slowly becomes historical fiction.

Runtime and deployment

At runtime, Caddy provides the public ingress and routes static surfaces or the Serverpod API. PostgreSQL is the authoritative store for match metadata, snapshots, events, and offsets. Redis supports Serverpod infrastructure but does not replace canonical persistence.

The accepted deployment direction is also explicit: build a release once in CI, bind its source SHA, image digest, static artifact hashes, migration revision, and configuration revision in a manifest, test that exact artifact in staging, and promote the same bytes to production.

That migration is not finished yet. The current host-side source pull and image build remain a transitional path rather than the target architecture.

What is complete, and what is still moving

The Architecture Atlas deliberately distinguishes implemented boundaries from accepted migration targets.

Already implemented:

  • separation of GameIntent, DomainCommand, SystemCommand, and DomainEvent
  • versioned multiplayer compatibility and strict wire envelopes
  • shared local/server command routing through GameEngine
  • recipient projection, command idempotency, ACK handling, and snapshot-first recovery
  • automated architecture budgets and historical ratchets

Still in progress:

  • narrowing remaining map consumers from the complete WorldMap to smaller read ports
  • completing the classification of pending workflows between deterministic domain evidence and client-only interaction
  • reducing GameEngineResult to a cleaner domain transition without presentation-oriented payloads
  • promoting immutable build artifacts through staging and production instead of rebuilding on the host

I think documenting these unfinished edges is more useful than presenting the project as a finished reference architecture. AoNW is a longterm open-source learning project, and the architecture is expected to evolve, but the ownership rules should remain stable while it does.

Why these boundaries matter

The goal is not to maximize the number of layers. It is to make dangerous shortcuts difficult:

  • a UI gesture cannot accidentally become a network command
  • AI cannot use a private alternative implementation of the rules
  • a recipient-projected snapshot cannot enter the engine
  • a retry cannot execute the same transition twice
  • rendering state cannot change a game outcome
  • existing architecture debt cannot silently grow in CI

https://github.com/ernestwisniewski/aonw

Top comments (0)