cd /news/developer-tools/inside-the-architecture-4x-strategy-… · home topics developer-tools article
[ARTICLE · art-86068] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

The open-source 4X strategy game Age of New Worlds has refactored its architecture so that local play, AI, replays, and multiplayer all converge on a single deterministic game engine. The project, built with Flutter, Flame, Dart, and Serverpod, separates presentation input from authoritative intent, ensuring consistent rule resolution across all modes. An interactive Architecture Atlas documents the current code and target design.

read7 min views1 publishedAug 4, 2026

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.

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

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

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

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.

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

The model now distinguishes four concepts:

GameIntent

InteractionState

, but it never enters the multiplayer protocol or authoritative event log.DomainCommand

DomainState

. It is the only kind of player-originated command accepted by the engine.SystemCommand

DomainEvent

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.

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

The target engine contract is conceptually simple:

apply(
  DomainState,
  DomainCommand | SystemCommand,
  EngineContext
) -> DomainTransition

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.

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

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.

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

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.

The repository's main local gate is:

make ci

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

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.

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.

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

Already implemented:

GameIntent

, DomainCommand

, SystemCommand

, and DomainEvent

GameEngine

Still in progress:

WorldMap

to smaller read portsGameEngineResult

to a cleaner domain transition without presentation-oriented payloadsI 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.

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

── more in #developer-tools 4 stories · sorted by recency
── more on @age of new worlds 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/inside-the-architect…] indexed:0 read:7min 2026-08-04 ·