<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ernest</title>
    <description>The latest articles on DEV Community by Ernest (@ernest_dev).</description>
    <link>https://dev.to/ernest_dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4004256%2Fdd9db165-523e-420b-9531-cb51419d1546.jpg</url>
      <title>DEV Community: Ernest</title>
      <link>https://dev.to/ernest_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ernest_dev"/>
    <language>en</language>
    <item>
      <title>Inside the Architecture 4x Strategy Game: One Core for Local Play, AI and Multiplayer</title>
      <dc:creator>Ernest</dc:creator>
      <pubDate>Tue, 04 Aug 2026 12:24:12 +0000</pubDate>
      <link>https://dev.to/ernest_dev/inside-the-architecture-4x-strategy-game-one-core-for-local-play-ai-and-multiplayer-8l1</link>
      <guid>https://dev.to/ernest_dev/inside-the-architecture-4x-strategy-game-one-core-for-local-play-ai-and-multiplayer-8l1</guid>
      <description>&lt;p&gt;&lt;a href="https://aonw.net" rel="noopener noreferrer"&gt;Age of New Worlds&lt;/a&gt; is an open-source, hex-based 4X strategy game built with Flutter, Flame, Dart, and Serverpod.&lt;/p&gt;

&lt;p&gt;You can explore the full system in the &lt;a href="https://aonw.net/architecture" rel="noopener noreferrer"&gt;interactive Architecture Atlas&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;which part of the system actually owns the rules&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The current refactor is built around one constraint:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Local play, AI, replay, simulations, and multiplayer must all end in the same deterministic game engine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I recently published an &lt;a href="https://aonw.net/architecture" rel="noopener noreferrer"&gt;interactive Architecture Atlas&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The central rule: one authoritative core
&lt;/h2&gt;

&lt;p&gt;At a high level, the architecture looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Flutter UI      AI / MCTS       Replay       Serverpod
     |               |              |              |
     +------- DomainCommand / SystemCommand -------+
                            |
                     aonw_core GameEngine
                            |
             accepted / rejected deterministic result
                    /             |              \
             UI projection    persistence     simulation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;authenticating the actor&lt;/li&gt;
&lt;li&gt;ordering commands&lt;/li&gt;
&lt;li&gt;enforcing idempotency&lt;/li&gt;
&lt;li&gt;persisting snapshots and events&lt;/li&gt;
&lt;li&gt;projecting recipient-safe views&lt;/li&gt;
&lt;li&gt;broadcasting accepted results&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; mean maintaining a second implementation of the game rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repository boundaries
&lt;/h2&gt;

&lt;p&gt;The main repository areas have deliberately different responsibilities:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lib/game/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flutter client, Riverpod state, Flame rendering, application services, local persistence, and adapters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;packages/aonw_core/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dart-only commands, state, deterministic rules, shared protocol models, replay contracts, and AI planning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;packages/aonw_server_client/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Generated Serverpod client used by the Flutter application&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Authentication, matchmaking, multiplayer orchestration, recipient projection, persistence, and realtime streams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docs/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ADRs, protocol contracts, quality policies, runbooks, and gameplay documentation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not every click is a game command
&lt;/h2&gt;

&lt;p&gt;One of the most useful changes was separating presentation input from authoritative intent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; tap / click / shortcut
           |
       GameIntent ------------------&amp;gt; InteractionState
           |
           +-- when complete --&amp;gt; DomainCommand ---&amp;gt; GameEngine

 trusted scheduler / server -------&amp;gt; SystemCommand ---&amp;gt; GameEngine
                                                       |
                                                  DomainEvent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model now distinguishes four concepts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GameIntent&lt;/code&gt;&lt;/strong&gt; describes client interaction such as selecting a unit, focusing a tile, opening a panel, entering targeting mode, or cancelling a preview. It may change client-local &lt;code&gt;InteractionState&lt;/code&gt;, but it never enters the multiplayer protocol or authoritative event log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DomainCommand&lt;/code&gt;&lt;/strong&gt; is a complete immutable request to change &lt;code&gt;DomainState&lt;/code&gt;. It is the only kind of player-originated command accepted by the engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SystemCommand&lt;/code&gt;&lt;/strong&gt; represents trusted transitions such as timeout resolution or forced turn finalization. It is not exposed through player command endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DomainEvent&lt;/code&gt;&lt;/strong&gt; records an accepted domain fact. It is output, not another command or an instruction for the UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  One engine, different execution adapters
&lt;/h2&gt;

&lt;p&gt;Local and network play take different routes to the same engine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; LOCAL
 UI -&amp;gt; GameIntentResolver -&amp;gt; LocalCommandResolver
    -&amp;gt; GameEngine.apply(...) -&amp;gt; client projection

 ONLINE
 UI -&amp;gt; GameIntentResolver -&amp;gt; versioned WireCommand
    -&amp;gt; authenticated Serverpod adapter
    -&amp;gt; GameEngine.apply(...)
    -&amp;gt; atomic persistence -&amp;gt; ACK / projected broadcast
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The target engine contract is conceptually simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apply(
  DomainState,
  DomainCommand | SystemCommand,
  EngineContext
) -&amp;gt; DomainTransition
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;EngineContext&lt;/code&gt; captures every external value that can affect a rule: the immutable &lt;code&gt;WorldMap&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;GameEngineResult&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  One authoritative state, several explicit projections
&lt;/h2&gt;

&lt;p&gt;State ownership follows the same principle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; MapDraft -- validate + freeze --&amp;gt; immutable WorldMap
                                         |
 DomainCommand + EngineContext --------&amp;gt; GameEngine
                                         |
                                  immutable DomainState'
                                         |
             +---------------------------+------------------------+
             |                           |                        |
 CanonicalGameSnapshot          recipient projection     client composition
 metadata + state + offset       RecipientSnapshot        InteractionState
                                                          RenderState
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MapDraft&lt;/code&gt; is the only mutable map representation and belongs to the editor. Gameplay receives a validated, immutable &lt;code&gt;WorldMap&lt;/code&gt; with indexed hex lookup.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DomainState&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Client-only concepts do not belong there. Selection, focus, open panels, targeting previews, camera state, animation state, and rendering caches live in &lt;code&gt;InteractionState&lt;/code&gt; or derived &lt;code&gt;RenderState&lt;/code&gt; projections.&lt;/p&gt;

&lt;p&gt;Persistence uses &lt;code&gt;CanonicalGameSnapshot&lt;/code&gt;, which contains metadata, the complete authoritative state, and one applied event offset. Multiplayer clients receive a nominally different &lt;code&gt;RecipientSnapshot&lt;/code&gt;, projected for a specific player and potentially missing hidden information. A recipient snapshot is never valid engine input.&lt;/p&gt;

&lt;p&gt;That type-level separation is important: a convenient network view should not accidentally become a substitute for canonical state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multiplayer: ACK, retry, projection, and recovery
&lt;/h2&gt;

&lt;p&gt;The multiplayer path adds transport concerns without adding another rules engine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Client A                  Server                         Client B
    |                         |                              |
    | command(id = 42)        |                              |
    |------------------------&amp;gt;| validate + apply             |
    |                         | persist state/event/offset    |
    |&amp;lt;--------- ACK ----------|                              |
    |                         |------ projected event ------&amp;gt;|
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every command carries a &lt;code&gt;clientMessageId&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Movement is also carried as explicit authoritative evidence. Protocol events include ordered &lt;code&gt;movementExecutions&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture is executable, not only documented
&lt;/h2&gt;

&lt;p&gt;The repository's main local gate is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make ci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It combines several checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runtime and deployment
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That migration is not finished yet. The current host-side source pull and image build remain a transitional path rather than the target architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is complete, and what is still moving
&lt;/h2&gt;

&lt;p&gt;The Architecture Atlas deliberately distinguishes implemented boundaries from accepted migration targets.&lt;/p&gt;

&lt;p&gt;Already implemented:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;separation of &lt;code&gt;GameIntent&lt;/code&gt;, &lt;code&gt;DomainCommand&lt;/code&gt;, &lt;code&gt;SystemCommand&lt;/code&gt;, and &lt;code&gt;DomainEvent&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;versioned multiplayer compatibility and strict wire envelopes&lt;/li&gt;
&lt;li&gt;shared local/server command routing through &lt;code&gt;GameEngine&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;recipient projection, command idempotency, ACK handling, and snapshot-first recovery&lt;/li&gt;
&lt;li&gt;automated architecture budgets and historical ratchets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Still in progress:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;narrowing remaining map consumers from the complete &lt;code&gt;WorldMap&lt;/code&gt; to smaller read ports&lt;/li&gt;
&lt;li&gt;completing the classification of pending workflows between deterministic domain evidence and client-only interaction&lt;/li&gt;
&lt;li&gt;reducing &lt;code&gt;GameEngineResult&lt;/code&gt; to a cleaner domain transition without presentation-oriented payloads&lt;/li&gt;
&lt;li&gt;promoting immutable build artifacts through staging and production instead of rebuilding on the host&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why these boundaries matter
&lt;/h2&gt;

&lt;p&gt;The goal is not to maximize the number of layers. It is to make dangerous shortcuts difficult:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a UI gesture cannot accidentally become a network command&lt;/li&gt;
&lt;li&gt;AI cannot use a private alternative implementation of the rules&lt;/li&gt;
&lt;li&gt;a recipient-projected snapshot cannot enter the engine&lt;/li&gt;
&lt;li&gt;a retry cannot execute the same transition twice&lt;/li&gt;
&lt;li&gt;rendering state cannot change a game outcome&lt;/li&gt;
&lt;li&gt;existing architecture debt cannot silently grow in CI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/ernestwisniewski/aonw" rel="noopener noreferrer"&gt;https://github.com/ernestwisniewski/aonw&lt;/a&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>gamedev</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
