{"slug": "inside-the-architecture-4x-strategy-game-one-core-for-local-play-ai-and", "title": "Inside the Architecture 4x Strategy Game: One Core for Local Play, AI and Multiplayer", "summary": "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.", "body_md": "[Age of New Worlds](https://aonw.net) is an open-source, hex-based 4X strategy game built with Flutter, Flame, Dart, and Serverpod.\n\nYou can explore the full system in the [interactive Architecture Atlas](https://aonw.net/architecture)\n\nAs 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**.\n\nLocal 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.\n\nThe current refactor is built around one constraint:\n\nLocal play, AI, replay, simulations, and multiplayer must all end in the same deterministic game engine.\n\nI recently published an [interactive Architecture Atlas](https://aonw.net/architecture) 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.\n\nAt a high level, the architecture looks like this:\n\n```\n Flutter UI      AI / MCTS       Replay       Serverpod\n     |               |              |              |\n     +------- DomainCommand / SystemCommand -------+\n                            |\n                     aonw_core GameEngine\n                            |\n             accepted / rejected deterministic result\n                    /             |              \\\n             UI projection    persistence     simulation\n```\n\nThe Flutter client and the Serverpod backend are adapters around the same Dart-only core. The server remains authoritative, but authority means:\n\nIt does **not** mean maintaining a second implementation of the game rules.\n\nThe main repository areas have deliberately different responsibilities:\n\n| Area | Responsibility |\n|---|---|\n`lib/game/` |\nFlutter client, Riverpod state, Flame rendering, application services, local persistence, and adapters |\n`packages/aonw_core/` |\nDart-only commands, state, deterministic rules, shared protocol models, replay contracts, and AI planning |\n`packages/aonw_server_client/` |\nGenerated Serverpod client used by the Flutter application |\n`server/` |\nAuthentication, matchmaking, multiplayer orchestration, recipient projection, persistence, and realtime streams |\n`docs/` |\nADRs, protocol contracts, quality policies, runbooks, and gameplay documentation |\n\nThe 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.\n\nOne of the most useful changes was separating presentation input from authoritative intent.\n\n``` php\n tap / click / shortcut\n           |\n       GameIntent ------------------> InteractionState\n           |\n           +-- when complete --> DomainCommand ---> GameEngine\n\n trusted scheduler / server -------> SystemCommand ---> GameEngine\n                                                       |\n                                                  DomainEvent\n```\n\nThe model now distinguishes four concepts:\n\n`GameIntent`\n\n`InteractionState`\n\n, but it never enters the multiplayer protocol or authoritative event log.`DomainCommand`\n\n`DomainState`\n\n. It is the only kind of player-originated command accepted by the engine.`SystemCommand`\n\n`DomainEvent`\n\nThis 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.\n\nLocal and network play take different routes to the same engine.\n\n``` php\n LOCAL\n UI -> GameIntentResolver -> LocalCommandResolver\n    -> GameEngine.apply(...) -> client projection\n\n ONLINE\n UI -> GameIntentResolver -> versioned WireCommand\n    -> authenticated Serverpod adapter\n    -> GameEngine.apply(...)\n    -> atomic persistence -> ACK / projected broadcast\n```\n\nThe target engine contract is conceptually simple:\n\n``` php\napply(\n  DomainState,\n  DomainCommand | SystemCommand,\n  EngineContext\n) -> DomainTransition\n```\n\n`EngineContext`\n\ncaptures every external value that can affect a rule: the immutable `WorldMap`\n\n, 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.\n\nThe 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.\n\nThe 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`\n\n, 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.\n\nState ownership follows the same principle.\n\n``` php\n MapDraft -- validate + freeze --> immutable WorldMap\n                                         |\n DomainCommand + EngineContext --------> GameEngine\n                                         |\n                                  immutable DomainState'\n                                         |\n             +---------------------------+------------------------+\n             |                           |                        |\n CanonicalGameSnapshot          recipient projection     client composition\n metadata + state + offset       RecipientSnapshot        InteractionState\n                                                          RenderState\n```\n\n`MapDraft`\n\nis the only mutable map representation and belongs to the editor. Gameplay receives a validated, immutable `WorldMap`\n\nwith indexed hex lookup.\n\n`DomainState`\n\nis 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.\n\nClient-only concepts do not belong there. Selection, focus, open panels, targeting previews, camera state, animation state, and rendering caches live in `InteractionState`\n\nor derived `RenderState`\n\nprojections.\n\nPersistence uses `CanonicalGameSnapshot`\n\n, which contains metadata, the complete authoritative state, and one applied event offset. Multiplayer clients receive a nominally different `RecipientSnapshot`\n\n, projected for a specific player and potentially missing hidden information. A recipient snapshot is never valid engine input.\n\nThat type-level separation is important: a convenient network view should not accidentally become a substitute for canonical state.\n\nThe multiplayer path adds transport concerns without adding another rules engine.\n\n```\n Client A                  Server                         Client B\n    |                         |                              |\n    | command(id = 42)        |                              |\n    |------------------------>| validate + apply             |\n    |                         | persist state/event/offset    |\n    |<--------- ACK ----------|                              |\n    |                         |------ projected event ------>|\n```\n\nEvery command carries a `clientMessageId`\n\n. 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.\n\nFor 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.\n\nReconnect 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.\n\nMovement is also carried as explicit authoritative evidence. Protocol events include ordered `movementExecutions`\n\nwith 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.\n\nThe repository's main local gate is:\n\n```\nmake ci\n```\n\nIt combines several checks:\n\n```\n make ci\n   |- generated-code drift\n   |- formatting and fatal static analysis\n   |- dependency boundaries and repository census\n   |- file, type, nesting, cyclomatic, and cognitive budgets\n   |- mutation tests for critical behavior\n   |- deterministic performance workloads\n   |- coverage floors and changed-line coverage\n   `- package, contract, and generated-client tests\n```\n\nThe 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.\n\nEvery 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.\n\nAt 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.\n\nThe 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.\n\nThat migration is not finished yet. The current host-side source pull and image build remain a transitional path rather than the target architecture.\n\nThe Architecture Atlas deliberately distinguishes implemented boundaries from accepted migration targets.\n\nAlready implemented:\n\n`GameIntent`\n\n, `DomainCommand`\n\n, `SystemCommand`\n\n, and `DomainEvent`\n\n`GameEngine`\n\nStill in progress:\n\n`WorldMap`\n\nto smaller read ports`GameEngineResult`\n\nto 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.\n\nThe goal is not to maximize the number of layers. It is to make dangerous shortcuts difficult:", "url": "https://wpnews.pro/news/inside-the-architecture-4x-strategy-game-one-core-for-local-play-ai-and", "canonical_source": "https://dev.to/ernest_dev/inside-the-architecture-4x-strategy-game-one-core-for-local-play-ai-and-multiplayer-8l1", "published_at": "2026-08-04 12:24:12+00:00", "updated_at": "2026-08-04 12:50:59.950058+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["Age of New Worlds", "Flutter", "Flame", "Dart", "Serverpod", "Architecture Atlas"], "alternates": {"html": "https://wpnews.pro/news/inside-the-architecture-4x-strategy-game-one-core-for-local-play-ai-and", "markdown": "https://wpnews.pro/news/inside-the-architecture-4x-strategy-game-one-core-for-local-play-ai-and.md", "text": "https://wpnews.pro/news/inside-the-architecture-4x-strategy-game-one-core-for-local-play-ai-and.txt", "jsonld": "https://wpnews.pro/news/inside-the-architecture-4x-strategy-game-one-core-for-local-play-ai-and.jsonld"}}