[AoNW] The Refactor So Far: Breaking Up the Runtime Around the Game Engine

Written by

in

, , ,

Since the previous refactor update, Age of New Worlds has kept one canonical GameEngine, but the runtime around it has changed substantially. The main game-state provider, Flame renderer, network session layer, and Serverpod multiplayer services have been split into explicit responsibilities. At the same time, presentation parity tests and aggregate architecture budgets now make it harder for the same complexity to grow back under different filenames.

Finishing the shared game engine did not finish the refactor.

It exposed the next layer of the problem.

In the previous update, local play, multiplayer, AI simulations, and replay finally entered the same engine and received the same canonical snapshot, ordered events, and authoritative movement or combat facts.

That removed several alternative interpretations of the game rules.

The classes surrounding the engine were still doing too much orchestration, however. GameStateNotifier knew about bootstrapping, command dispatch, live synchronization, caching, and effects. GameRenderer coordinated input, lifecycle, state synchronization, transitions, camera behaviour, and visual layers. The multiplayer server still had large reducers, stores, and projectors that grouped several capabilities behind shared private state.

The rules were becoming calm. The runtime around them was not there yet.

This stage moved the refactor outward. (ernest.dev)

The refactor moved from truth to orchestration

The current direction looks more like this:

flowchart LR
    UI["UI and input"] --> State["GameStateNotifier<br/>thin Riverpod facade"]
    State --> App["Application services<br/>bootstrap, commands, sync, effects"]
    App --> Engine["Canonical GameEngine"]
    Engine --> Result["Snapshot and ordered facts"]
    Result --> Presentation["Presentation policy<br/>and scheduler"]
    Presentation --> Renderer["GameRenderer<br/>composed runtime handlers"]

    App --> Ports["Application-owned ports"]
    Adapters["Serverpod, storage<br/>and platform adapters"] -. implement .-> Ports
    Root["Riverpod composition root"] --> Adapters

The previous stage answered the question: where is the game allowed to decide what is true?

This stage answers a different question: which object is allowed to coordinate each step around that truth?

That distinction matters because a shared engine can still be surrounded by large classes that quietly recreate different lifecycles, retry rules, synchronization paths, or presentation behaviour.

GameStateNotifier is now a facade, not a subsystem

GameStateNotifier had gradually become the place where many unrelated runtime concerns met.

It created the game session, built reducers and use cases, dispatched commands, synchronized the active player, opened the live event stream, retried snapshots, reconciled multiplayer interaction state, cached received snapshots, updated connection status, and forwarded effects to the renderer.

None of those responsibilities was unreasonable on its own. The problem was their combined ownership.

The notifier has now been reduced to a small Riverpod-facing facade. It owns the public methods expected by the UI and delegates the real work to four explicit services:

  • GameStateApplicationBootstrap prepares the runtime and initial state;
  • GameStateCommands handles commands and presentation intents;
  • GameStateMultiplayerSync owns live snapshots and event synchronization;
  • GameStateEffects publishes the resulting UI and renderer effects.

A separate GameStateRuntime keeps the mutable runtime references that these services share deliberately.

The public shape is now close to this:

class GameStateNotifier extends _$GameStateNotifier {
  late final GameStateRuntime _runtime = GameStateRuntime();
  late final GameStateCommands _commands = /* ... */;
  late final GameStateApplicationBootstrap _bootstrap = /* ... */;

  @override
  Future<GameClientState> build(String saveId) =>
      _bootstrap.buildState(saveId);

  Future<List<UiEffect>> dispatch(DomainCommand command) =>
      _commands.dispatch(command);
}

The important change is not the shorter file by itself.

An earlier decomposition could have moved methods into Dart part files while leaving one object responsible for everything. That improves navigation, but it does not create a real boundary because every part still shares the same private state.

The current services are standalone classes with explicit collaborators. An architecture test also keeps the notifier below 100 lines and prevents the old responsibility parts from returning.

The result is a provider that exposes state instead of becoming the entire game application layer.

Network session changes became typed transitions

The network session had a similar coordination problem.

A session is not only an access token. It also includes the authenticated identity, active player, active match, transport connection state, persisted match metadata, refresh behaviour, and the status displayed by the UI.

Previously, several providers and coordinators could update related pieces of this state. A successful join might update the active match, connection status, and persistent storage through separate calls. Leaving a match had to clear the same information in the correct order.

The session lifecycle is now modeled as typed actions, transitions, and effects:

flowchart LR
    Action["NetworkSessionAction"] --> Reducer["NetworkSessionReducer"]
    Current["Current transport state"] --> Reducer
    Reducer --> Next["Next state"]
    Reducer --> Effects["Typed effects"]
    Effects --> Store["Persist active match"]
    Effects --> Status["Publish or clear<br/>transport status"]

Actions describe intent, for example replacing a restored session, activating a match, clearing a match, remembering its identifier, or reporting a transport status.

The reducer returns a NetworkSessionTransition: the next in-memory state plus effects that still need to run. The effect runner is responsible for persistence and status publication, including ordered writes.

This keeps the transition policy deterministic and testable. Callers no longer need to remember which combination of providers, stores, and status notifiers must be updated for a particular session change.

It also separates two concepts that are easy to mix together:

The player can still belong to a match while the live transport is reconnecting.

Match identity and transport health are related, but they are not the same state.

Multiplayer contracts now belong to the application

After modeling the session explicitly, the next problem became dependency direction.

The presentation layer still knew too much about the concrete Serverpod client. Generated Serverpod types, authentication details, lobby requests, transport failures, and platform-specific social authentication could leak beyond the adapter boundary.

Those contracts now live under lib/game/application/ports.

The application owns stable concepts such as MultiplayerSessionGateway, NetworkSessionStorePort, LiveMultiplayerEvents, WireCommandDispatcher, NativeSocialAuthSession, NetworkSession, and MultiplayerFailure.

The implementations remain under lib/api, where Serverpod responses are converted into application-owned values and Serverpod errors are mapped into stable failures.

flowchart TB
    Presentation["Presentation"] --> Application["Application services"]
    Application --> Domain["Domain and GameEngine"]
    Application --> Port["Multiplayer application ports"]

    Adapter["Serverpod adapter"] -. implements .-> Port
    Platform["Native social-auth adapter"] -. implements .-> Port
    Composition["Composition root"] --> Adapter
    Composition --> Platform

This means the lobby and game UI no longer need to import generated sp.* models or understand how a concrete authentication provider represents success and failure.

Serverpod remains important infrastructure, but it is now an adapter to the game application rather than the owner of its vocabulary.

That makes the boundary easier to test and also makes future protocol changes less likely to spread through presentation code.

GameRenderer now composes runtime responsibilities

GameRenderer was the largest presentation hotspot.

A Flame world naturally sits at the intersection of many concerns. It owns components, receives pointer and keyboard input, follows the game lifecycle, synchronizes visual state, queues transitions, controls animations, moves the camera, and exposes notifiers back to Flutter.

The first useful step was to move groups of methods out of the main file. The more important step was to stop treating those groups as private extensions of one large object.

The renderer now composes dedicated runtime services:

flowchart TB
    Renderer["GameRenderer<br/>Flame boundary"]

    Renderer --> Input["GameRendererInputHandler"]
    Renderer --> Camera["GameRendererCameraSettings"]
    Renderer --> State["GameRendererStateSyncHandler"]
    Renderer --> Transition["GameRendererTransitionHandler"]
    Renderer --> Lifecycle["GameRendererLifecycleHandler"]

    Factory["GameRendererRuntimeFactory"] --> Renderer
    Components["Typed component registry"] --> Renderer

The input handler translates platform input into game intents. The state-sync handler keeps visual layers aligned with GameClientState. The transition handler serializes state changes and transient effects. The lifecycle handler owns loading, readiness, disposal, queued effects, and the camera/effect runtime. Camera options are held separately instead of being scattered through the renderer.

A runtime factory builds these collaborators, while a typed component registry gives the renderer reviewed access to its visual layers.

GameRenderer is still the Flame boundary. It should be. What changed is that it no longer has to be the implementation of every responsibility that crosses that boundary.

An architecture test keeps the host below 500 lines, requires the reviewed handlers to remain standalone classes, and prevents the earlier responsibility parts from returning.

Presentation now has an explicit authoritative timeline

The previous refactor established that the engine must return ordered facts, not only the next snapshot.

This stage made the presentation contract more explicit.

Every concrete domain event now has a reviewed animation policy. The policy says either that the event produces renderer effects or that no transient animation is required, together with a reason.

For example, CityFoundedEvent, UnitMovedEvent, CombatResolvedEvent, and TechnologyResearchedEvent create visible effects. A persistent state change such as a completed building may require no transient animation because the city layer already renders the result. TurnEndedEvent has no map animation because turn lifecycle remains state-driven.

The important part is exhaustiveness:

A new domain event cannot silently fall through presentation. It must explicitly choose an animation policy.

Projected effects are then grouped into batches with stable identities and, when available, an authoritative UTC start time. AuthoritativePresentationScheduler waits for that shared time before playback and reports when a batch arrives outside the accepted frame budget.

flowchart LR
    Source["Command result or network ACK"] --> Facts["Ordered events and<br/>authoritative evidence"]
    Facts --> Policy["Exhaustive animation policy"]
    Policy --> Batch["Projected effect batch<br/>identity, offset, start time"]
    Batch --> Scheduler["Authoritative scheduler"]
    Scheduler --> Renderer["Serialized renderer playback"]

    Resync["Duplicate, retry or reconnect"] --> Cursor["Identity and cursor checks"]
    Cursor --> Batch

The new parity corpus checks every domain event, every domain action, and every client interaction through the single-player and multiplayer presentation paths.

A separate multi-client harness deliberately delivers batches with duplicates, out-of-order arrival, latency jitter, reconnects, late joins, and replay. Every participating client must converge on the same reviewed animation trace, present each effect exactly once, and avoid overlapping authoritative batches.

This does not remove network latency. It gives latency, retry, and reconnection one explicit presentation policy instead of letting each client improvise from snapshot timing.

Server multiplayer facades became capability compositions

The same decomposition pattern was applied on the server.

Several server classes had become broad facades backed by large part files. The behaviour was tested, but capabilities such as command dispatch, turn policy, persistence, query projection, snapshot storage, and per-player visibility were still too close together.

The current structure composes explicit services:

flowchart TB
    Reducer["ServerCommandReducer"] --> Dispatcher["ServerCommandDispatcher"]
    Reducer --> Turn["ServerTurnPolicy"]
    Reducer --> Cache["ServerMapCache"]
    Reducer --> Outcome["ServerCommandOutcomeProjector"]

    Store["MultiplayerMatchStore"] --> Queries["Query store"]
    Store --> Persistence["Persistence store"]
    Store --> Snapshots["Snapshot store"]

    MatchView["PlayerMatchViewProjector"] --> Identity["Identity projection"]
    MatchView --> MatchSnapshot["Match snapshot projection"]
    MatchView --> Events["Event projection"]

    StateView["PlayerViewStateProjector"] --> World["World projection"]
    StateView --> Lifecycle["Lifecycle projection"]

The command facade now coordinates a map cache, turn policy, command dispatcher, and outcome projector. The match store delegates queries, persistence, and snapshots. Player projection is split between identity, world, lifecycle, snapshot, and event concerns.

These are not new versions of the game rules. They are narrower server capabilities around the same canonical engine and snapshot.

Architecture tests require these facades to compose services instead of hiding the responsibilities in part files. They also keep the removed reducer and store fragments from quietly returning under their old names.

Test code was part of the architecture debt

Production code was not the only place where responsibilities had accumulated.

Several important test suites had grown to thousands of lines. The AI strategy and MCTS suites, multiplayer realtime hub, game HUD, provider integration, and renderer input tests contained valuable coverage, but finding the scenario responsible for a failure was becoming increasingly expensive.

These suites are now thin hosts that register focused scenario groups and reuse explicit fixture builders.

The scenario files follow behaviour rather than arbitrary line ranges. AI tests are grouped around opening decisions, production, expansion, garrisons, settlers, military pressure, artifacts, and combat risk. Multiplayer hub tests are grouped around connection, lifecycle, commands, idempotency, quickplay, queries, resignations, and timeouts.

A new architecture check keeps each host below 100 lines, requires the reviewed minimum number of scenario groups, and limits scenario and fixture files so that they cannot simply become the next monolith.

This is a less visible part of the refactor, but it changes the cost of future work. Adding one regression case no longer requires extending a test file that already represents an entire subsystem.

Architecture budgets now follow logical libraries

The repository already had file-size, declaration-size, callable, nesting, cyclomatic, and cognitive-complexity budgets with a historical ratchet.

There was still one structural loophole.

Dart allows one logical library to be spread across an owner file and multiple handwritten part files. Moving 300 lines from a large host into a part can make the host look healthier while the actual review surface remains unchanged.

The new aggregate gate resolves every handwritten part back to its owner and measures the complete logical library:

flowchart LR
    Owner["owner.dart"] --> Library["Logical Dart library"]
    PartA["part_a.dart"] --> Library
    PartB["part_b.dart"] --> Library

    Library --> Metrics["Source lines<br/>callable count and lines<br/>cyclomatic complexity<br/>cognitive complexity"]
    Metrics --> Baseline["Exact aggregate baseline"]
    Baseline --> Ratchet{"New debt or growth?"}
    Ratchet -->|Yes| Fail["Fail architecture gate"]
    Ratchet -->|No| Pass["Pass"]

Targets still differ by role because a production service, Flame renderer, test library, and developer tool have different review shapes. Existing over-target libraries are recorded at their exact measured value. They may stay level or shrink, but they cannot grow. A new over-target library fails the gate.

The aggregate baseline is checked together with the existing per-file and per-callable baseline through:

make architecture

This connects the latest refactors into one rule:

Moving code is useful only when ownership becomes clearer. Moving code merely to improve a file metric is not an architectural improvement.

What disappeared

The most useful result is not the number of new service classes. It is the amount of ambiguous ownership that could be removed.

The project now has fewer:

  • provider methods coordinating bootstrapping, networking, persistence, and presentation at once;
  • UI dependencies on generated Serverpod models and failures;
  • renderer responsibility parts sharing unrestricted private state;
  • server reducers, stores, and projectors acting as hidden subsystems;
  • test files that contain every scenario for a large feature area;
  • opportunities to hide complexity by distributing one library across more files.

There are still facades and composition roots. The goal was not to eliminate coordination.

The goal was to make coordination visible, narrow, and reviewable.

What I learned from this stage

The previous stage made game truth canonical.

This stage made the route to that truth more explicit:

UI intent
  -> application service
  -> canonical engine transition
  -> snapshot and ordered facts
  -> presentation policy and schedule
  -> renderer playback

Infrastructure implements application ports. Presentation depends on application concepts. The engine owns game rules. The renderer owns visual execution. Persistence and network side effects are produced deliberately rather than being hidden inside state mutations.

The biggest lesson from this stage is therefore:

One game engine decides where truth lives. Small runtime boundaries decide whether that truth remains usable.

AoNW has not become a visibly different 4X game because GameStateNotifier is shorter or because the match store has three focused collaborators.

It has become easier to understand where a change belongs, easier to test one responsibility without constructing the complete runtime, and harder to reintroduce a second interpretation of session, server, or presentation behaviour.

The shared engine is still the center of the architecture.

The code around it is finally starting to behave like boundaries rather than another game engine in disguise.

Comments

Leave a Reply

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