[AoNW] Refactoring a Growing Flutter 4X Game Without Rewriting It

Written by

in

, ,

When I wrote the first article about Age of New Worlds, the project had reached the point where the core loop was real.

I could explore a hex map, found cities, move units, manage production, research technologies, improve terrain, run turns, and save the game. The interesting question was no longer whether Flutter and Flame could support a 4X game. The question was whether the code could keep growing without turning every new feature into a negotiation with the entire repository.

Since then, the project has grown in several directions at once.

Multiplayer moved to Serverpod. The authoritative server path became much more serious. The AI gained more planning and telemetry. The HUD and renderer became richer. More platforms entered the release process. The test suite grew into thousands of cases around rules, snapshots, transport, rendering contracts, migrations, and architecture boundaries.

That is the good news.

The less comfortable news is that a project can be well tested, carefully layered, and still accumulate too many versions of the truth.

So I recently stopped adding systems for a moment and audited the repository as a whole.

Not because the game stopped working.

Because it works well enough that the next architectural problems are finally visible.

The Architecture Did Its Job

The current architecture is not a failed experiment.

Commands helped keep rules out of the renderer. Ports made local and network persistence testable. Shared Dart models made it possible to run deterministic logic on the client and server. Serverpod replaced a large amount of custom networking and authentication code. Architecture tests catch forbidden dependencies before they spread quietly through the project.

I still believe in those decisions.

But useful abstractions can become too wide.

In the article about commands and reducers, I described the renderer as a source of commands rather than the owner of game truth. That boundary was important. It is still important.

The problem is that the command hierarchy eventually became responsible for two different languages:

  • temporary UI interaction such as tapping, selecting, focusing, and previewing
  • durable game decisions such as moving a unit, founding a city, choosing research, attacking, and ending a turn

Those things can start from the same click, but they do not have the same lifetime.

A camera focus request should not have to look like a server-authoritative command. A tile preview should not need a wire representation. A domain command should not depend on presentation state just because both once passed through the same reducer.

The original abstraction was useful. The refactor is about making it more precise.

The Shape I Found

The main problem is not one giant class or one especially bad file.

It is a set of parallel truths.

There is a mutable MapData model that is convenient for the editor and a second MapDefinition model used by other parts of the game. There is a Flutter-facing GameState, a PersistentGameState, and a SaveSnapshot, with manual copying between them. Local play and authoritative multiplayer execute similar rules through different reducer paths. UI intents and domain commands share one hierarchy. Large libraries are split into part files, which makes the files smaller without necessarily making the module smaller.

None of those decisions is irrational on its own.

Together, they create drift.

flowchart LR
    subgraph Client["Flutter client"]
        UI["Presentation"]
        Command["GameCommand<br/>UI intent + domain action"]
        LocalReducer["Local rule execution"]
        MapData["Mutable MapData"]
        ClientState["GameState / SaveSnapshot"]

        UI --> Command
        Command --> LocalReducer
        LocalReducer --> ClientState
    end

    subgraph Bridges["Conversion layer"]
        MapConverters["Repeated map converters"]
        StateConverters["Snapshot codecs<br/>and manual copies"]
        Wire["WireCommand / WireEvent"]
    end

    subgraph Server["Shared core and Serverpod"]
        MapDefinition["MapDefinition"]
        PersistentState["PersistentGameState"]
        ServerReducer["Server rule execution"]
        Store["Store and player projection"]
        Database[(PostgreSQL)]

        ServerReducer --> PersistentState
        PersistentState --> Store
        Store --> Database
    end

    Command --> Wire
    Wire --> ServerReducer

    MapData <--> MapConverters
    MapConverters <--> MapDefinition

    ClientState <--> StateConverters
    StateConverters <--> PersistentState

    LocalReducer -. duplicated behavior .-> ServerReducer

    Drift["Drift risk<br/>double fixes<br/>conversion cost"]
    MapConverters -.-> Drift
    StateConverters -.-> Drift
    LocalReducer -.-> Drift
    ServerReducer -.-> Drift
```

This diagram is intentionally uncomfortable.

It shows why a small gameplay change can require edits in the local reducer, server reducer, serializer, policy layer, snapshot conversion, AI simulation, and presentation effects. The layers exist, but too many of them can still describe the same fact independently.

That is the kind of complexity I want to remove.

Why I Am Not Rewriting the Game

A clean rewrite is tempting because the target architecture is easier to draw than the current migration path.

But a rewrite would throw away the most valuable part of the repository: accumulated behavior.

The tests describe movement edge cases, fog projection, city production, turn timing, command retries, snapshot compatibility, reconnect behavior, map constraints, AI decisions, and a long list of interactions that are easy to forget when looking at a diagram.

I do not want to rediscover those rules in a new codebase.

The refactor therefore has a strict rule:

Every meaningful step must leave the game buildable, testable, and playable.

Larger changes happen on separate branches. Before replacing a path, I add characterization and parity tests. I migrate one vertical capability at a time. When the new path is proven, I remove the old path instead of keeping both indefinitely.

The goal is not to build the new architecture beside the old one for six months.

The goal is to create a seam, move one responsibility through it, verify the result, and close the seam behind the migration.

The Target Shape

The architecture I want is smaller conceptually, even if the game continues to grow.

Flutter should own interaction and presentation. Serverpod should own transport, authentication, persistence coordination, and multiplayer authority. AI should use the same domain language as human players. One deterministic engine should apply durable commands to one immutable domain state.

flowchart TB
    UI["Flutter presentation"] --> Intent["GameIntent"]
    Intent --> Application["Application controllers<br/>and use cases"]

    Endpoint["Thin Serverpod endpoints"] --> Services["Server application services"]
    AI["AI and simulation"] --> DomainCommand["DomainCommand"]

    Application --> DomainCommand
    Services --> DomainCommand

    subgraph Domain["Deterministic domain core"]
        DomainState["Immutable DomainState"]
        Engine["Single GameEngine.apply"]
        DomainEvent["DomainEvent"]
        World["WorldMap + HexCoord"]

        DomainState --> Engine
        DomainCommand --> Engine
        Engine --> DomainEvent
        DomainEvent --> DomainState
        DomainState --> World
    end

    DomainState --> Projections["Interaction, render<br/>and per-player projections"]
    Projections --> UI
    Projections --> Services

    Application --> ClientPorts["Client ports"]
    ClientPorts --> ClientAdapters["Network and local persistence adapters"]

    Services --> ServerPorts["Server ports"]
    ServerPorts --> ServerAdapters["Database, stream<br/>and observability adapters"]

    ClientAdapters <--> Contracts["Versioned contracts<br/>codecs + upcasters"]
    Contracts <--> Endpoint
```

The central API can stay boring:

final class GameEngine {
  DomainTransition apply({
    required DomainState state,
    required DomainCommand command,
    required GameContext context,
  });
}

The important part is not the method signature.

It is who is allowed to call it and what is not allowed inside it.

GameIntent belongs to the Flutter application. It can represent selection, preview, focus, inspection, or a request to build a real command. DomainCommand represents a durable attempt to change the game. DomainEvent describes what the engine accepted. Renderer effects are projections of state and events, not part of the authoritative transition itself.

The same GameEngine should be used by local play, Serverpod, replay tools, and AI simulation.

If those paths need different rules, the difference should be explicit in GameContext, not hidden in a second implementation.

Stage 0: Make the Edges Honest

The first changes are deliberately unglamorous.

Before moving domain models, I want the repository boundaries to tell the truth.

One example was the release flow. The aggregate release target ran its quality gate, changed the version in a new commit, and then pushed and published. The tested commit and the released commit were not necessarily the same thing. Store uploads were also enabled by default.

The first hardening branch made uploads opt-in and repeated the full release check after the version commit, before push or publication.

Another example was the multiplayer lobby request. A Flutter-side request exposed custom match rules, AI players, and a display name even though the generated Serverpod contract did not accept those values. The server already owns standard online rules and reads the display name from the authenticated account. Multiplayer currently allocates human seats only.

Instead of adding half-supported protocol fields, I removed the false options from the client request and added tests around the exact generated endpoint arguments.

These changes are small, but they express the theme of the refactor:

A narrow honest contract is better than a broad contract that quietly ignores half of its input.

The remaining edge work includes trusted-proxy-aware rate limiting, stricter Docker build contexts, aligned tool versions, generated-code drift checks, and explicit production configuration.

Stage 1: Measure Before Moving

Refactoring a strategy game without performance and compatibility baselines is just moving uncertainty around.

Before changing the core models, I want to record several things:

  • local and server transitions for the same command families
  • save, snapshot, replay, and protocol fixtures
  • coverage by risk area rather than one global percentage
  • map lookup and pathfinding timings
  • event-log behavior at 100, 1,000, and 10,000 events
  • AI turn and simulation budgets
  • frame-time percentiles and memory behavior on representative maps
  • reconnect and command-ACK behavior through the real server boundary
  • artifact and asset size per platform.

Some of this already exists in tests and tools. The missing part is turning it into a ratchet.

If a refactor improves the folder structure but makes late-game turns slower, it is not automatically an improvement. If a new serializer is cleaner but cannot read the previous save format, it is not ready. If local and server tests pass independently but produce different transitions, the green checks are proving two different systems.

The gate needs to compare the promises that matter.

Stage 2: One World and One State

The first large domain refactor is the map.

The editor needs mutation. The game does not.

I want one immutable WorldMap used by rules, AI, saves, and the server, with an indexed HexCoord lookup. The editor can use a dedicated MapDraft and commit it into the canonical map model.

That removes repeated MapData to MapDefinition conversion code and makes tile lookup constant-time instead of repeatedly scanning a list in rule-heavy paths.

The state follows the same principle.

There should be one deeply immutable DomainState for authoritative game truth and one GameSnapshot containing metadata, that state, and the event offset. Selection, previews, dialog state, camera animation, and asset readiness belong in separate InteractionState and RenderState models.

This migration needs fixture-based save upgrades. Old saves and replays are part of the product, not an inconvenience to delete when a model becomes cleaner.

It also gives me a good moment to fix local persistence pressure: make the latest event offset an indexed operation, avoid loading a snapshot that is already in memory, and move large encoding work away from the UI isolate.

Stage 3: One Engine, Migrated by Command Family

Once map and state have one canonical representation, the two rule paths can converge.

I do not plan to switch every command in one commit.

The migration loop looks like this:

flowchart LR
    Stage0["Stage 0<br/>Honest contracts, auth and release"]
    Stage1["Stage 1<br/>Parity, E2E and measurable gates"]
    Stage2["Stage 2<br/>Canonical WorldMap and DomainState"]

    Stage0 --> Stage1 --> Stage2 --> Characterize

    subgraph Loop["Stage 3 — repeat for each command family"]
        Characterize["Characterize current behavior"]
        Seam["Add target seam<br/>inside the domain core"]
        Migrate["Migrate one vertical capability"]
        Check{"Parity, compatibility and<br/>performance budgets pass?"}
        Remove["Route all callers to the new path<br/>and delete the duplicate"]
        More{"More command families?"}

        Characterize --> Seam --> Migrate --> Check
        Check -- No --> Migrate
        Check -- Yes --> Remove --> More
        More -- Yes --> Characterize
    end

    More -- No --> Stage4["Stage 4<br/>Enforce real module boundaries"]
    Stage4 --> Stage5["Stage 5<br/>Immutable delivery and operability"]
```

Movement can move first, then combat, city production, research and diplomacy, and finally turn resolution.

For each family:

  1. record local and server behavior
  2. introduce the shared engine handler
  3. route local play, Serverpod, and AI through it
  4. compare transitions and events
  5. remove the replaced reducer branch.

This is slower than writing a new engine in isolation.

It is much faster than discovering months later that the new engine forgot a visibility rule, a save migration, or a multiplayer timeout case.

Stage 4: Make Modules Real

The project has many files, but file count is not the same thing as modularity.

A class split across a main file and ten part files is still one private library with one large surface. A file-size test can say every file is acceptable while the aggregate module continues to grow.

The next step is to replace physical splits with behavioral components.

The renderer can be decomposed into input, camera, state synchronization, transitions, effects, and lifecycle collaborators. The main game notifier can stop owning bootstrap, command queues, stream recovery, snapshot application, persistence, and presentation effects at the same time. Multiplayer session handling can become an explicit state machine with events and effects instead of a collection of booleans and overlapping in-flight futures.

On the server, reducers and projectors can be composed from handlers per capability instead of one large switch distributed across extensions.

Only after those dependencies form an acyclic graph do I want to consider physically splitting aonw_core into smaller packages.

Package boundaries should confirm architecture that already exists.

They should not be used to pretend the dependency graph is clean.

Stage 5: Code Quality Does Not End at Main

The repository can have excellent Dart code and still deliver an unreliable system.

A green commit is not enough if the release process rebuilds it from a mutable checkout. A health endpoint is not monitoring. An alert rule file is not an alert until a real notification reaches an operator. A backup that has never been restored is still a theory.

The target release path builds once and promotes the same artifact:

flowchart LR
    Commit["Reviewed commit"] --> Gate{"Quality and security gates green?"}
    Gate -- No --> Fix["Fix in a new commit"]
    Fix --> Commit

    Gate -- Yes --> Tag["Approved SHA / signed tag"]
    Tag --> Build["Build once"]
    Build --> Bundle["Immutable bundle<br/>app artifacts + image digest<br/>SBOM + provenance"]

    Bundle --> Staging["Deploy same digest to staging"]
    Staging --> Verify{"Smoke, canary and SLO checks pass?"}
    Verify -- No --> Stop["Stop promotion"]
    Verify -- Yes --> Production["Promote same digest to production"]

    Production --> Health{"Production SLOs healthy?"}
    Health -- Yes --> Serve["Continue serving"]
    Health -- No --> Alert["Notify operator"]
    Alert --> Rollback["Rollback to previous digest"]

    Production --> Database[(PostgreSQL)]
    Database --> Backup["Encrypted off-site backup"]
    Backup --> Restore["Scheduled restore drill"]
    Restore --> Report{"Restore verified?"}
    Report -- No --> Alert
    Report -- Yes --> Serve
```

Database migrations should be a separate observable step and follow an expand-contract strategy. Staging and production should receive the same digest. Releases should carry checksums, an SBOM, provenance, and signatures. Rollback should be exercised before it is needed.

Operationally, I want correlation IDs, structured logs, real metrics, tested alerts, synthetic multiplayer probes, backup freshness checks, and scheduled restore reports.

That may sound far from hex rendering and city production.

It is not.

If a multiplayer turn disappears, a snapshot cannot be restored, or a release cannot be traced to the tested commit, the player does not care how clean the reducer was.

What 10/10 Means for This Repository

I do not think a repository becomes perfect because every class has fewer than 300 lines or because global coverage reaches an impressive number.

For this project, the useful definition is behavioral:

  • one canonical representation of map and authoritative state
  • one engine for local play, server execution, replay, and AI
  • UI intent never masquerades as a durable network command
  • generated DTOs stay inside adapters
  • dependency direction is acyclic and automatically enforced
  • saves and protocol versions have explicit upgrade paths
  • performance and artifact size are measured and ratcheted
  • critical journeys run through real integration boundaries
  • a release can be traced to one reviewed commit and one immutable artifact
  • production can be observed, rolled back, and restored

The final test is simpler:

Does the next feature require fewer unrelated edits than the previous one?

If adding a new command means changing one domain handler, one codec registration, and focused tests, the architecture is improving. If it still means coordinating two reducers, three state models, several conversion helpers, and presentation-specific command branches, the refactor is not finished.

The Part I Want to Preserve

The most important thing is that this plan does not reject the original architecture.

The renderer still should not own game truth. Commands and events still matter. Deterministic Dart rules are still the reason local play, AI, saves, and multiplayer can share so much code. Serverpod is still the right home for authoritative transport and persistence coordination. Flutter and Flame are still a surprisingly good combination for this kind of game.

What changes is the precision of the boundaries.

The renderer sends intent.

The application decides whether that intent becomes a domain command.

The engine decides what happened.

The server decides what becomes authoritative online.

The projections decide what each player is allowed to see.

The release process proves which version reached them.

That is the next version of the architecture I want for Age of New Worlds: fewer parallel truths, smaller contracts, measurable migrations, and a game that remains playable while its foundations become calmer.

The project is still growing.

The refactor is how I want to make sure the code can keep growing with it.

Comments

Leave a Reply

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