[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 basic game loop was already working.

I could explore a hex map, found cities, move units, manage production, research technologies, improve terrain, end turns, and save the game. Flutter and Flame had already proved they could handle a 4X game. The new question was whether I could keep adding features without making the code harder to change every time.

Since then, the project has grown in several areas.

Multiplayer now uses Serverpod, and the server is responsible for more of the game. The AI has better planning and telemetry. The HUD and renderer do more. The release process supports more platforms. There are now thousands of tests for game rules, snapshots, networking, rendering, migrations, and architecture boundaries.

That is the good part.

The problem is that the project now has too many versions of the same information.

So I paused feature work and reviewed the whole repository.

Not because the game stopped working.

Because it is now large enough for the next set of architecture problems to become clear.

The Architecture Worked

The current architecture was useful.

Commands kept game rules out of the renderer. Ports made local and network storage easier to test. Shared Dart models let the client and server run deterministic logic. Serverpod replaced a lot of custom networking and authentication code. Architecture tests stop unwanted dependencies from spreading.

I still want to keep these ideas.

Some of the abstractions just became too broad.

In the article about commands and reducers, I explained why the renderer sends commands instead of owning the game state. That separation is still important.

The problem is that one command hierarchy now covers two different things:

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

Both can start with the same click, but they have different jobs.

Focusing the camera should not look like a command that must be approved by the server. Previewing a tile should not need a network format. A game command should not depend on UI state just because both once went through the same reducer.

The old abstraction helped the project grow.

Now it needs a clearer boundary.

Too Many Versions of the Same Thing

There is no single terrible class or file. The problem is duplication across the project.

The editor uses a mutable MapData model, while other parts of the game use MapDefinition. The client has GameState, PersistentGameState, and SaveSnapshot, with manual copying between them. Local games and multiplayer run similar rules through different reducers. UI actions and game commands use the same hierarchy. Large libraries are split into part files, which reduces file size but does not reduce the size of the module.

Each choice made sense at the time.

Together, they make it easy for implementations to drift apart.

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
```

A small gameplay change can require updates to the local reducer, server reducer, serializer, policy layer, snapshot conversion, AI simulation, and UI effects. The code is split into layers, but several layers still describe the same rule in their own way.

That is the kind of duplication I want to remove.

Why I Am Not Rewriting the Game

Starting again would make the target architecture easier to draw and easier to build.

It would also throw away a lot of tested behavior.

The tests cover movement edge cases, fog of war, city production, turn timing, retries, save compatibility, reconnects, map rules, AI decisions, and many small interactions that would be easy to miss in a rewrite.

I do not want to discover all those rules again.

The refactor follows one main rule:

After every meaningful step, the game must still build, pass its tests, and remain playable.

Large changes happen on separate branches. Before replacing an old path, I add tests that describe its current behavior and compare it with the new one. I move one complete feature at a time. Once the new path works, I delete the old one.

I do not want the old and new architecture to run side by side for months.

The plan is to create a clear boundary, move one responsibility across it, test it, and then remove the temporary bridge.

The Target Architecture

The goal is a simpler model of the system, even as the game gets bigger.

Flutter owns input and presentation. Serverpod owns transport, authentication, persistence coordination, and multiplayer authority. The AI uses the same game commands as human players. One deterministic engine applies game commands to one immutable game 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 main API can be simple:

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

The method itself is not the difficult part.

The important question is what belongs inside it and which parts of the app may call it.

GameIntent belongs to the Flutter app. It can mean select, preview, focus, inspect, or request a real game action. DomainCommand is an attempt to change the game. DomainEvent describes what the engine accepted. Visual effects are created from state and events, they are not part of the authoritative game transition.

Local play, Serverpod, replay tools, and AI simulation should all use the same GameEngine. If they need different rules, those differences should be visible in GameContext, not hidden in another engine implementation.

Stage 0: Make the Boundaries Match the Code

The first changes are small but important. Before moving core models, I want the repository boundaries to match what the system really does.

One example was the release process. It ran the quality checks, created another commit with the new version, and then published that new commit. This meant the released commit was not always the tested commit. Store uploads were also on by default.

I changed the process so uploads require an explicit option. It also runs the full release check again after the version commit and before anything is pushed or published.

Another example was the multiplayer lobby request. The Flutter client offered custom match rules, AI players, and a display name, but the generated Serverpod API did not accept those values. The server already chooses the standard online rules and gets the display name from the signed-in account. Multiplayer currently supports human seats only.

I removed those unsupported options from the client request and added tests for the exact endpoint arguments.

The rule is simple:

A small contract that does exactly what it says is better than a large one that ignores some of its inputs.

Other work in this stage includes rate limiting that understands trusted proxies, smaller Docker build contexts, matching tool versions, generated-code checks, and clear production configuration.

Stage 1: Measure Before Changing the Core

Before changing the main models, I need baselines for performance and compatibility.

I want to record:

  • local and server results for the same command types
  • save, snapshot, replay, and protocol fixtures
  • test coverage for important areas, not only one global number
  • map lookup and pathfinding speed
  • event-log behavior with 100, 1,000, and 10,000 events
  • time limits for AI turns and simulations
  • frame times and memory use on representative maps
  • reconnect and command acknowledgement through a real server
  • artifact and asset sizes for each platform

Some of these checks already exist.

The next step is to turn them into limits that future changes cannot silently make worse.

A cleaner folder structure is not useful if late-game turns become slower. A better serializer is not ready if it cannot read old saves. If local and server tests pass separately but produce different results, they are testing two different games.

The tests need to compare the behavior players depend on.

Stage 2: One Map, One Game State

The first large change is the map model.

The map editor needs to change data while someone is editing. The running game does not.

I want one immutable WorldMap for rules, AI, saves, and the server, with indexed lookup by HexCoord. The editor can use a separate MapDraft and turn it into the final map when the user saves it.

This removes repeated conversions between MapData and MapDefinition. It also makes tile lookup constant-time instead of scanning a list during rule-heavy operations.

The game state should follow the same approach.

There should be one deeply immutable DomainState for game truth. A GameSnapshot should contain metadata, that state, and the event offset. Selection, previews, dialogs, camera animations, and asset loading should live in separate InteractionState and RenderState models.

This change needs tested upgrades for old save files. Saves and replays are part of the game, so a cleaner model is not a reason to break them.

It is also a good time to improve local persistence: index the latest event offset, avoid loading a snapshot that is already in memory, and move large encoding jobs off the UI isolate.

Stage 3: Move One Command Type at a Time

Once the map and state have one canonical form, the local and server rule paths can start using the same engine.

I will not move every command in one commit.

The process 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 go first, followed by combat, city production, research, diplomacy, and finally turn resolution.

For each group of commands, I will:

  1. record the current local and server behavior
  2. add a handler to the shared engine
  3. make local play, Serverpod, and the AI use it
  4. compare state changes and events
  5. delete the old reducer code

This takes longer than writing a new engine by itself.

It also lowers the risk of forgetting an old visibility rule, save migration, or multiplayer timeout case.

Stage 4: Split by Responsibility

Having many files does not automatically make a project modular.

A class split between one main file and ten part files is still one large Dart library. A file-size check may pass even while the whole module keeps growing.

I want to split code by responsibility instead.

The renderer can have separate collaborators for input, camera control, state synchronization, transitions, effects, and lifecycle. The main game notifier should not handle startup, command queues, stream recovery, snapshots, persistence, and UI effects all at once. Multiplayer sessions can use an explicit state machine instead of many booleans and overlapping futures.

On the server, each feature can have its own command handler and projector instead of adding more cases to a large reducer switch.

I will only consider splitting aonw_core into smaller packages after the dependencies form a clear graph without cycles.

Package boundaries should reflect a structure that already works.

Creating more packages does not fix unclear dependencies by itself.

Stage 5: Make Releases Reliable

Clean Dart code is only one part of a reliable game.

A release is risky if it is rebuilt from a changing checkout. A health endpoint is not the same as monitoring. An alert configuration is not useful until it reaches a real person. A backup has little value until someone has successfully restored it.

The target release process builds an artifact once and promotes that 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, visible step and use an expand-contract approach. Staging and production should use the same build digest. Releases should include checksums, an SBOM, provenance, and signatures. Rollback should be tested before an emergency.

I also want correlation IDs, structured logs, useful metrics, tested alerts, synthetic multiplayer checks, backup freshness checks, and regular restore reports.

This may seem separate from rendering hexes or managing cities.

It is not.

Players see the whole system. If a turn disappears, a save cannot be restored, or a release cannot be linked to the tested commit, clean reducer code does not help them.

What Good Looks Like for This Project

I do not need every class to stay below an arbitrary line count, and one high test-coverage number does not prove that the architecture is good.

For this project, success means:

  • one representation of the map and authoritative game state
  • one engine for local play, the server, replays, and AI
  • UI actions are separate from networked game commands
  • generated data-transfer objects stay inside adapters
  • dependencies have a clear direction, checked by tests
  • saves and protocols have explicit upgrade paths
  • performance and build size are measured
  • important user journeys are tested through real system boundaries
  • each release points to one reviewed commit and one unchanged artifact
  • production can be monitored, rolled back, and restored

The most useful question is:

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

If a new command only needs one game handler, one codec registration, and focused tests, the architecture is getting better. If it still requires changes to two reducers, three state models, several converters, and UI-specific command branches, there is more work to do.

What Stays the Same

This plan does not reject the original architecture.

The renderer still should not own the game state. Commands and events still matter. Deterministic Dart rules still make it possible to share code between local play, AI, saves, and multiplayer. Serverpod is still responsible for authoritative networking and persistence coordination. Flutter and Flame still work well for this game.

The boundaries just need to be clearer:

  • the renderer sends an intent
  • the application decides whether it becomes a game command
  • the engine applies the rules
  • the server decides what is authoritative in multiplayer
  • projections decide what each player can see
  • the release process proves which version reached players

That is the next architecture I want for Age of New Worlds: fewer duplicate models, smaller contracts, measurable changes, and a game that stays playable during the refactor.

The project is still growing.

This refactor should make the next feature easier to add, not harder.

Comments

Leave a Reply

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