Age of New Worlds is strongly DDD-inspired. I use domain boundaries, application ports, infrastructure adapters, commands, events, projections, and persistent snapshots because a 4X game becomes complicated very quickly. The architecture is there to answer one question:
How do I keep the game understandable when rendering, UI, AI, saving, hotseat play, and multiplayer all want to touch the same state?
The Simplified Project Tree
flame_4x/
lib/
game/
domain/ # client-side game state, reducers, save model
application/ # use cases, services, ports
infrastructure/ # local persistence, event logs, transports
presentation/ # Flutter UI, Riverpod, Flame renderer, HUD
map/ # map data, geometry, loading, rendering
editor/ # map editor and content tools
api/ # network client, codecs, websocket transport
packages/
aonw_core/
lib/
game/domain/ # shared commands, events, rules, runtime state
map/domain/ # shared map definitions
ai/ # AI planning, scoring, simulations
protocol/ # wire models shared by client and server
server/
lib/
auth/ # users, JWT, password hashing
domain/ # server command handling and validation
http/ # Shelf routes
matchmaking/ # match lifecycle and sessions
persistence/ # PostgreSQL repositories
websocket/ # live event streams
The most important split is between three things:
aonw_corecontains shared domain concepts.lib/gamecontains the Flutter client’s game boundary.server/libcontains the authoritative multiplayer boundary.
The Big Picture
flowchart TD UI["Flutter UI and HUD"] Renderer["Flame Hex Renderer"] App["Application Layer"] Ports["Ports"] Local["Local Adapters"] Network["Network Adapters"] Server["Dart Server"] Core["aonw_core Shared Domain"] DB["PostgreSQL / Local JSON"] UI --> App Renderer --> App App --> Ports Ports --> Local Ports --> Network Local --> DB Network --> Server Server --> DB App --> Core Server --> Core
The renderer does not own the game. Flutter does not own the game. The server does not reuse the client UI model.
The shared language is made of commands, events, state, rules, and wire models.
The project has several bounded contexts:
flowchart LR Core["Shared Kernel: commands, events, rules, map, AI"] Client["Client Game Context"] Server["Server Match Context"] Editor["Editor Context"] Api["Protocol Context"] Client --> Core Server --> Core Editor --> Core Client --> Api Server --> Api
I think of aonw_core as a shared kernel. It contains things both the client and server must agree on: command names, event names, map definitions, units, cities, rulesets, fog of war, research, runtime state, and AI helpers.
The Flutter client adds presentation state and local interaction state. The server adds authorization, persistence, replay protection, match lifecycle, and player-specific projections.
That distinction matters because multiplayer cannot trust the client’s visual model.
Commands Are the Language of Intent
A player action is represented as a command.
sealed class GameCommand {
const GameCommand();
}
Commands are not UI callbacks. They are domain-level intent: move this unit, found this city, select this technology, submit this turn.
The client dispatches them through an application port:
abstract interface class CommandTransport {
Future<CommandTransportResult> dispatch({
required String saveId,
required GameState currentState,
required GameCommand command,
GameCommandContext context = const GameCommandContext(),
});
}
That port can be implemented locally or over the network.
flowchart TD Input["Tap / HUD Button / AI Decision"] Command["GameCommand"] Transport["CommandTransport"] Local["LocalCommandTransport"] Net["NetworkCommandTransport"] Reducer["Reducer / Server Handler"] Result["State + Events + Snapshot"] Input --> Command Command --> Transport Transport --> Local Transport --> Net Local --> Reducer Net --> Reducer Reducer --> Result
This is one of the most important architectural choices in the project. The rest of the system can evolve because commands remain the stable language.
Local Play and Multiplayer Share a Shape
Local play uses a local transport. It reduces the command, writes events, saves the snapshot, and returns the next state.
Network play uses a network transport. It sends authoritative commands to the server and receives an acknowledged snapshot and filtered events.
Some commands are client-only. Selecting a tile, opening targeting mode, focusing the next action, or changing local interaction state does not need to go to the server. Moving a unit, founding a city, choosing research, attacking, or submitting a turn does.
That distinction keeps multiplayer clean:
flowchart TD
Command["GameCommand"]
Command --> ClientOnly{"Client-only?"}
ClientOnly -->|"yes"| LocalReduce["Reduce locally"]
ClientOnly -->|"no"| SendServer["Send to server"]
SendServer --> Validate["Validate actor, tick, command"]
Validate --> Reduce["Server command handler"]
Reduce --> Persist["Append event + save snapshot"]
Persist --> Project["Project per player"]
Project --> Ack["Return ack + snapshot"]The server is authoritative for the match. The client is authoritative only for local interaction state.
Events and Snapshots
The game does not persist only “the latest object graph”. It also keeps an event log offset.
A snapshot contains durable state:
class SaveSnapshot {
final GameSave save;
final Map<String, int> playerGold;
final List<GameUnit> units;
final List<GameCity> cities;
final FogOfWarState fogOfWar;
final ResearchState research;
final GameRuntimeState runtimeState;
final int eventLogOffset;
}
Conceptually:
flowchart LR Command["Command"] Reduction["Reduction"] Events["Event Log"] Snapshot["Save Snapshot"] Client["Client State"] Command --> Reduction Reduction --> Events Reduction --> Snapshot Snapshot --> Client
The event log is useful for command history, synchronization, live updates, replay protection, and debugging. The snapshot is useful because a 4X game cannot rebuild everything from the beginning every time the player loads a match.
The architecture is not full event sourcing. It is closer to command sourcing plus periodic/current snapshots.
Projections
Projections are where multiplayer becomes serious.
The server stores authoritative state. But each player should only receive the state they are allowed to know. Fog of war makes this unavoidable.
The SnapshotVisibilityProjector takes a server snapshot and produces a player-specific snapshot.
It keeps the player’s own data, visible enemy units, visible cities, visible improvements, that player’s fog, that player’s research, and only runtime actions that belong to that player.
flowchart TD Full["Authoritative Snapshot"] Fog["Fog of War"] Player["Player Id"] Projector["Snapshot Visibility Projector"] View["Player-Specific Snapshot"] Full --> Projector Fog --> Projector Player --> Projector Projector --> View
This is the key idea:
The server may know everything, but the client should receive a view.
The same idea applies to events. A city founded in visible territory may be sent to a player. A hidden enemy movement should not be. The event visibility filter decides which events are visible for each player.
The Server Command Pipeline
On the server, command handling is deliberately strict.
sequenceDiagram participant C as Client participant R as HTTP Route participant T as ServerCommandTransport participant V as Validator / Authorizer participant D as Domain Handler participant P as Persistence participant W as WebSocket C->>R: POST /matches/:id/commands R->>T: dispatch command T->>V: validate tick and replay T->>P: load latest snapshot T->>D: reduce command D-->>T: save, state, events T->>P: append event T->>P: save snapshot T->>W: broadcast projected events T-->>C: ack with projected snapshot
The server also protects against duplicate or stale ticks. If the same actor sends the same tick again, the server can return a cached acknowledgement instead of applying the command twice.
That matters because network clients retry. The domain should not accidentally move a unit twice because a request was duplicated.
Why This Shape Helps
This architecture gives me a few useful properties:
- The renderer can stay focused on rendering.
- The HUD can stay focused on player interaction.
- The domain can stay focused on rules.
- Local play and multiplayer use the same command language.
- Save/load is part of the design, not a late feature.
- Fog of war becomes a projection boundary, not just a dark overlay.
- AI can produce commands instead of clicking UI.
- Tests can exercise reducers, transports, projections, and architecture rules separately.
The trade-off is that there are more layers than a tiny prototype needs.
But Age of New Worlds is no longer just a tiny prototype. Movement touches fog. Fog touches combat. Combat touches events. Events touch multiplayer. Multiplayer touches projections. Projections touch persistence. Persistence touches migrations.
At that point, architecture is not decoration. It is how I keep the game from turning into one large object with a map inside it.
The Rule I Keep Coming Back To
The architecture is built around one rule:
The game has one truth, but many views of that truth.
The domain describes the truth.
The renderer displays a view.
The HUD controls a view.
The save system persists a view of durable state.
The server owns the authoritative view.
Each multiplayer client receives a projected view.
That is the shape I want Age of New Worlds to grow into: a strategy game where each system can become richer without every other system having to know too much about it.
Leave a Reply