[AoNW] General architecture overview

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_core contains shared domain concepts.
  • lib/game contains the Flutter client’s game boundary.
  • server/lib contains 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.

Comments

Leave a Reply

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