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: