[AoNW] The Refactor So Far: Finishing the Shared Game Engine Refactor

Written by

in

, , ,

Since the previous refactor update, Age of New Worlds has moved local play, multiplayer, AI simulations, replay, presentation effects, and match lifecycle behind one canonical game engine. The visible result is fewer synchronization bugs. The architectural result is one authoritative path from command to state, events, and animation.

Since the previous refactor update, most of the work has not been about adding another visible system.

It has been about removing alternative ways in which the same game could be interpreted.

The earlier stages brought local play and multiplayer closer together, especially around authoritative movement, turn processing, fog of war, saves, and replays. That solved many individual inconsistencies, but the architecture still had several places where a command could take a slightly different route depending on who executed it.

Since then, the dev branch has moved through versions 1.1.7 and 1.1.8, followed by another round of multiplayer and release fixes.

The main result is simple to describe:

Local games, the multiplayer server, AI simulations, and replay now use the same game engine and the same canonical state transitions.

The implementation was not quite as simple.

One engine instead of several almost-identical games

Previously, AoNW had a shared domain, but it did not yet have one complete execution path.

A local command could pass through a Flutter-side reducer. The server had its own authoritative reduction path. AI simulations used lighter state adapters. Replay reconstructed parts of the presentation from stored commands and snapshots.

These paths shared a lot of code, but “sharing a lot of code” is not the same as producing the same result.

The new flow looks more like this:

flowchart LR
    UI["UI / controller input"] --> Split{"Intent or domain command?"}

    Split -->|Presentation intent| Interaction["Local interaction state"]
    Split -->|Authoritative command| Engine["GameEngine"]

    Server["Multiplayer server"] --> Engine
    AI["AI simulation"] --> Engine
    Replay["Replay"] --> Engine

    Engine --> Result["Next canonical snapshot<br/>Ordered domain events<br/>Movement and combat facts"]

    Result --> Persistence["Save / event log / network projection"]
    Result --> Presentation["HUD and renderer projection"]

The engine classifies commands into explicit families: unit actions, movement, combat, cities, production, workers, artifacts and trade, research, diplomacy, and turn processing.

Every supported domain command enters through the same boundary:

final result = engine.apply(
  snapshot: snapshot,
  command: command,
  context: context,
);

switch (result) {
  case GameEngineAccepted():
    // Use the returned snapshot and ordered facts.
  case GameEngineRejected():
    // Keep the input snapshot unchanged.
}

The important part is not the dispatcher itself. A switch statement does not make an architecture.

The important part is that local play, the server, AI, and replay no longer own separate interpretations of what an accepted command means.

A tap is not a game command

One of the most useful separations in this stage was between presentation intent and authoritative domain command.

Selecting a unit, opening attack targeting, focusing a city, moving a cursor, or tapping a tile are real user interactions, but most of them do not change the game world.

Previously, some of these interactions lived too close to the command path. That made it easier for local UI state to leak into persistence, replay, or the multiplayer protocol.

The new intent resolver keeps these responsibilities apart.

A client intent may update selection, targeting mode, a movement preview, or camera focus. It may also produce a domain command when the player confirms an action. That command is returned separately and must still pass through the game engine.

A tile tap can lead to a move, but the tap itself is not the move.

This sounds like a small distinction, but it removes a surprising amount of ambiguity. Selection and focus no longer need to cross the network boundary. Movement, combat, production, diplomacy, and other actual game changes still do.

One canonical snapshot, with explicit internal boundaries

Version 1.1.7 concentrated heavily on state.

The map, local commands, AI preparation, saves, replay, and server projections were moved onto the same canonical snapshot model. Existing save and replay formats were preserved, but the internal read paths stopped creating different views of the same match whenever possible.

The snapshot is not one giant undifferentiated object. It is an envelope around separate concerns:

flowchart TB
    Snapshot["CanonicalGameSnapshot"]

    Snapshot --> Domain["Domain state<br/>units, cities, diplomacy, fog, economy"]
    Snapshot --> Session["Match session<br/>turn submissions, timeouts, AFK state"]
    Snapshot --> Metadata["Metadata<br/>map, rules, timestamps, replay data"]
    Snapshot --> Interaction["Persisted multi-step interaction"]
    Snapshot --> Offset["Event-log offset"]

This made several smaller improvements possible:

  • save snapshots now preserve unknown fields when they are decoded and written again
  • replay and AI read from the same canonical state instead of legacy projections
  • event-log offset lookup no longer needs to scan the complete command history
  • lightweight movement previews stay outside persistence work
  • large serialization jobs can still use isolates, while frequent tiny operations no longer create unnecessary isolate churn

This was one of the stages where an architectural cleanup also improved responsiveness. The fastest operation is often the operation that no longer enters a subsystem it never needed.

The engine returns facts, not only state

Returning the next snapshot was not enough.

The renderer, HUD, replay, and multiplayer observers also need to know what happened, in what order, and which movement or combat details are safe to present.

An accepted engine result now carries:

  • the next canonical snapshot
  • ordered domain events
  • authoritative movement execution data
  • combat animation facts

Presentation is projected from those facts through one boundary instead of being reconstructed independently by several consumers.

This matters most when two snapshots look similar but the path between them is important.

A unit may have moved through three hexes, discovered terrain, triggered combat, retreated, or completed a queued route. A simple comparison of the state before and after cannot reliably reconstruct that sequence.

The same applies after reconnecting. If the client receives a newer snapshot, it should not replay effects it has already displayed. Projected effect batches therefore receive stable identities based on their source, event offset, entity, and order.

The renderer can now distinguish between:

  • a new authoritative effect
  • an already presented effect received again after synchronization
  • a local interaction effect that should never be persisted

That removed another source of duplicate movement, repeated alerts, and animations appearing in the wrong order.

Multiplayer movement was still the hardest edge

Movement continued to expose the most difficult synchronization cases.

The server knows the exact approved path. The acting player should see it. An observer should see it only when fog-of-war rules allow it. A reconnecting client must receive enough evidence to reproduce the same animation without learning hidden route information.

The current model separates two facts:

Exact movement evidence contains the complete path and its costs. It can be shown only to reviewed recipients.

Coarse movement evidence contains the public origin and destination, but not the hidden intermediate route.

The distinction became important around transitions between two fog states.

An observer may see the entire route before movement, or the entire route after movement. That is coherent exact-path visibility. Another observer may see only the origin in the previous state and the destination in the next state. That can justify a coarse movement event, but not disclosure of the path between them.

The latest fixes also cover a less obvious case: an observer can be entitled to the exact movement evidence even though the moved unit leaves that observer’s final projected view.

Without additional audience information, the snapshot projector could remove the unit and then also remove the movement event needed to animate its departure.

The server now keeps the reviewed exact-path audience aligned with the corresponding movement events. Server-only audience metadata remains in canonical storage and is stripped before data crosses the player wire.

This produced a rule that is much easier to reason about:

If a recipient is allowed to receive the exact path, the recipient must also receive the event that anchors that path on the presentation timeline.

The same work fixed local interaction reconciliation after multiplayer updates. Stale movement previews are cleared when pathfinding inputs change, valid targeting can remain active after a recoverable rejected move, and a new turn no longer carries an obsolete pending skip or preview forward.

These are UI details, but they now follow from authoritative state instead of fighting it.

Match lifecycle became a typed state machine

Match status used to be represented across several server paths as strings and conditional rules.

That worked until multiple operations attempted to start, finish, abandon, reconnect to, or clean up the same match at nearly the same time.

The lifecycle is now modeled explicitly:

stateDiagram-v2
    [*] --> Open
    Open --> Running: start
    Open --> Abandoned: cancelled or stale

    Running --> Finished: victory, score, resignation or draw
    Running --> Abandoned: leave, cleanup or protocol upgrade

    Finished --> Finished: same terminal result
    Abandoned --> Abandoned: same terminal result

Invalid transitions are rejected. Repeating the same terminal transition is idempotent. A finished match cannot silently return to an active phase.

This gives matchmaking, timeouts, resignations, completion, reconnects, and cleanup one transition policy instead of several similar policies spread across services.

It also separates lifecycle from connection state. A player reconnecting is not the same thing as a match changing phase.

Domain events now drive visible behaviour

A smaller visible change helped validate the new direction.

When a fortified unit detects a threat, it now remains fortified and idle. The camera focuses the unit, while blue markers identify only enemies visible within its sight.

Previously, it would have been tempting to express part of this behaviour by mutating unit state just to make the UI react.

Now the domain reports the threat and the presentation layer decides how to focus the camera and draw markers.

The unit does not need to become “temporarily unfortified” to explain a visual alert.

That is the type of separation I want more of: domain facts remain true, while the presentation is free to make those facts readable.

The safety net grew with the architecture

A refactor of this size is useful only when it becomes harder to accidentally reintroduce the old paths.

The project now has stronger checks around:

  • local and server command parity
  • canonical snapshot boundaries
  • allowed architecture dependencies
  • generated code drift
  • mutation coverage for critical rules
  • deterministic performance budgets
  • changed-line coverage
  • critical multiplayer journeys
  • release configuration and platform packaging.

The release flow also became stricter on macOS.

The app is signed, notarized, stapled, packaged without AppleDouble or __MACOSX metadata, extracted again, and verified with both code-signing checks and Gatekeeper assessment. The same validation is applied to public downloads, Steam packages, and the macOS build prepared for itch.io.

This is not the central part of the game-engine refactor, but it follows the same principle: a release artifact is not accepted because a command completed. It is accepted because the resulting artifact passes the checks that matter.

What disappeared

The most valuable result is not the number of new engine classes. It is the amount of alternative behaviour that could be deleted.

The project no longer needs as many:

  • persistent command resolvers duplicating domain rules
  • client reducers interpreting commands differently from the server
  • fabricated network snapshot boundaries
  • replay-specific state reconstruction paths
  • renderer guesses based only on snapshot differences
  • string-based match transition rules
  • persistence work for temporary movement previews

There are still adapters. A Flutter client, multiplayer server, AI search, and replay player have different responsibilities.

They just no longer get to define different game rules.

What I learned from this stage

At first, I described the goal as “making local and multiplayer use the same rules.”

That was correct, but incomplete.

The real boundary has to start earlier and end later:

intent
  -> authoritative command
  -> deterministic engine transition
  -> canonical snapshot and ordered facts
  -> recipient-safe projection
  -> idempotent presentation

If any step invents information that the previous step did not provide, another version of the game begins to grow there.

The biggest lesson from this stage is therefore:

One game engine is not merely one class. It is an agreement about where truth is allowed to exist.

AoNW now has a much clearer version of that agreement.

Local play, multiplayer, AI, and replay enter the same engine. UI-only intent stays local. Match lifecycle is typed. Movement visibility is projected per recipient. Presentation consumes ordered facts. Release artifacts are verified after packaging.

The visible game has not suddenly become a different 4X title because of this work.

It has become a calmer foundation on which the next visible systems can be built.

Comments

Leave a Reply

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