In the previous post, I described a plan to refactor Age of New Worlds without rewriting it.
The plan looked clean on paper.
Then the real work started.
The goal was not to move files around or rename a few classes. The goal was to remove duplicate paths while keeping the game working after every step.
The first safety work is now complete. Most of the new quality gates are running. The map migration is far advanced. Some real inconsistencies were found and fixed along the way.
But the refactor is not finished.
The map is close to the target architecture. The state and game engine are not there yet.
This is a progress report about what changed, what worked, and what is still ahead.
The Main Rule Did Not Change
The refactor still follows one important rule:
After every meaningful change, the game must build, pass its tests, and remain playable.
I do not want a second version of the game living next to the first one for several months.
Instead, I move one small area at a time:
- Describe the current behaviour with tests.
- Add the new boundary.
- Move one complete use case to it.
- Compare local and server results.
- Measure the performance.
- Delete the old path.
The original plan assumed separate branches for larger changes. I changed that process.
The work now moves through small, focused commits on the long-lived dev branch. The main branch stays untouched until I make a deliberate release or merge decision.
This makes the progress easier to follow. It also prevents one large branch from becoming a second repository with its own history.
Stage 0 Is Done: Honest Contracts and Safer Releases
The first stage was not about the map or the game engine.
It was about making existing boundaries tell the truth.
One example was match creation.
The Flutter client exposed options for custom match rules and AI players. The generated Serverpod endpoint did not accept them. Those values could disappear between the client and server.
That is a dangerous kind of contract.
The application looks like it supports something, but the server never receives it.
I removed the unsupported options from the multiplayer request. Online matches currently use the standard rules and human seats. The display name comes from the signed-in account.
There are now mapper and round-trip tests that check the complete request.
The rule is simple:
A small contract that handles every field is better than a large contract that ignores some of them.
Authentication rate limiting also needed work.
The server runs behind a proxy, so the direct connection address is not always the real client address. The rate limiter now uses proxy-aware client information inside an explicit trust model.
The tests cover:
- different clients behind the same proxy,
- fallback behaviour,
- missing proxy information,
- spoofing attempts.
Two real clients now receive separate rate-limit buckets instead of being treated as one address.
I also protected Serverpod Insights behind the deployed access layer. The Docker build context no longer includes local environment files, keys, certificates, or backups. The Serverpod CLI and runtime versions are aligned. Production starts with an explicit production run mode.
The release flow changed too.
Before this work, the quality gate could run, create a new version commit, and then publish that new commit. The released SHA was not always the SHA that passed the gate.
Now the full gate runs again after the version change and before a push or publication.
Store uploads are also disabled by default. Publishing requires an explicit decision.
This does not yet solve the complete immutable artifact problem. That belongs to a later stage.
But it does solve an important source-control problem:
The commit selected for release is the commit that passed the checks.
None of this is exciting gameplay work.
That is exactly why it is useful to finish it early.
The Quality Gate Became Measurable
The repository already had more than four thousand tests, strict static analysis, architecture tests, and CI for the main packages.
The missing part was not test quantity.
The missing part was a clear answer to this question:
Did this refactor make an important property worse?
A green test suite does not answer that by itself.
So I added more measurable gates.
The workspace now has one pinned Flutter and Dart setup and one bootstrap path. The root package, core package, client, and server share the same base lint configuration.
Generated code is also checked for drift. This includes Freezed, Riverpod, localization, and Serverpod output. Running generation must leave the working tree clean.
Coverage is no longer treated as one global percentage.
There are baselines for separate layers and a check for changed code. Existing coverage cannot quietly fall because a large unrelated area keeps the global number high.
Architecture checks also changed.
Some older checks were based on file names, line counts, and regular expressions. The new policy uses the Dart syntax tree. It distinguishes production code, tests, tools, and rendering code.
It measures more than file size:
- function and method size,
- nesting,
- cyclomatic complexity,
- cognitive complexity,
- library-level growth.
The current baseline is treated as recorded debt, not as the ideal target. A new change cannot make that debt larger without an explicit decision.
Mutation tests now cover important reducers, serializers, and authentication rules. They do not only ask whether tests pass. They make small changes to the implementation and check whether the test suite notices.
There are also broader end-to-end scenarios for:
- starting a new game, saving it, and loading it again,
- authentication, match creation, command dispatch, and reconnect.
Benchmarks now cover map access, persistence, replay, AI work, and renderer frame budgets.
One part of this stage remains open: some Dart and HTML checks still use regular expressions where an AST or DOM check would describe the real meaning better.
Most of the safety net is now in place.
That was necessary before changing the central models.
The Biggest Change So Far: One Map
The map was the first large migration.
Before the refactor, the project used two main representations.
The editor and parts of the client used MapData. Other parts of the core and server used MapDefinition.
The code converted between them in many places.
That caused several problems.
A rule could receive a different map type depending on where it ran. A conversion could lose a field. A hot path could copy the whole map only to inspect one tile. The local game, server, AI, and replay code could slowly start using different assumptions.
The first step was to introduce one independent coordinate type:
HexCoord
It does not belong to the editor, renderer, or game state.
Then I introduced an immutable, indexed WorldMap.
final class WorldMap {
WorldTile? tileAt(HexCoord coord);
}
The real implementation has more behaviour and metadata, but the important part is small.
A running game can read the world. It cannot mutate the map through a public list.
Tile lookup uses an index, so tileAt is constant-time.
The editor still needs mutable data while someone is creating a map. That work now belongs to MapDraft. The draft becomes an immutable map when it is frozen.
The old MapDefinition type has been removed.
Its exports, tests, public APIs, and production projections are gone.
MapData still exists at a few older boundaries, but it is no longer the main model used by game rules.
Rules Now Ask for the Smallest Map They Need
Moving every rule directly to WorldMap would already reduce duplication.
But it would still give many services more access than they need.
Combat usually needs to inspect a tile.
Movement needs map dimensions and repeated tile lookup.
A domination check may need to inspect the full map.
The AI may need several of those operations through one stable view.
So the refactor introduced small read-only contracts.
A rule can receive a tile lookup, traversal view, survey, or combined read view. It does not receive a mutable DTO or a full conversion helper.
The important idea is:
A rule should see the smallest useful view of the world.
City founding, worker actions, improvements, building requirements, resource checks, unit production, wonders, research, fog of war, movement, auto-explore, combat, retreat, merchants, and turn resolution now use these read views.
The economy simulation uses one indexed view for its full run.
The AI and MCTS simulations also use one shared map view. They no longer rebuild a complete map for every simulated action.
Replay and server execution use the same contracts.
The final WorldMapReadView returns immutable WorldTile objects directly from the indexed map. It does not create a second TileData object for every lookup.
This removed full-map conversions from the important rule paths.
It also avoided another tempting solution: a global conversion cache.
A global cache would hide the cost, keep old representations alive, and create invalidation problems. Instead, a request can reuse one read view and cache only the tiles it actually visits.
The cost stays visible.
The Performance Results Are Easier to Explain
The new benchmarks do not only record time.
They also count work.
For a fog reveal with range three, the visible area has a fixed size. The benchmark reports 37 visible hexes and 223 tile lookups on maps with 100, 1,000, and 10,000 tiles.
The full map can grow.
The fog operation does not grow with it.
A movement test over a fixed distance performs 60 lookups and visits 59 unique tiles. This also stays stable when the total map becomes larger.
Auto-explore is different.
It can scan a full connected part of the map, so its cost grows with the number of reachable tiles. That behaviour is now recorded as known performance debt instead of being hidden behind one average timing number.
This is one reason I wanted benchmarks before the migration.
A refactor should not only produce nicer types. It should make the cost of important operations clear.
The Legacy Adapter Is Almost Empty
There is still a compatibility boundary between MapData and WorldMap.
It is called LegacyWorldMapAdapter.
Earlier in the migration, it had several helpers. It could create tile projections, traversal views, read views, and full maps.
Most of that API is now gone.
The adapter only supports the remaining full conversions between the old and new representation.
There are two production conversion points left:
- the server map cache,
MapDraft.freeze.
Architecture tests record their exact paths and owners. Adding another conversion somewhere else fails the gate.
When those last boundaries are migrated, the adapter and its allowlist can be deleted.
That will be the real end of the map duplication.
The Refactor Also Found Real Drift
A useful refactor should do more than improve diagrams.
It should expose places where two paths stopped carrying the same information.
One example was rush production.
The persistent path did not forward the player’s artifacts into the production calculation. The current artifact catalog does not contain a production bonus, so this was not yet a visible gameplay bug.
But the contract was still wrong.
The value is now forwarded, and a focused test fails when that connection is removed.
The AI had a similar problem.
Several economy and science projections did not include the correct artifacts. That affected empire assessment, technology pressure, economy health, MCTS scoring, production caches, and telemetry.
Those paths now receive the correct artifact state.
City selection was another source of drift.
The selected city can be refreshed after direct selection, a turn change, production, expansion, or the end of a turn. These paths built almost the same view, but not always with the same context.
They now use one CitySelectionProjector.
The projector receives the rulesets, game pace, artifacts, all cities, wonder information, and player colour explicitly.
That means a selected city should no longer show different values only because it was refreshed by a different action.
This work is not fully complete.
Some AI economy and science projections still need the complete wonder context. MCTS still needs a clear information model for artifacts owned by simulated opponents. I also need explicit decisions about food artifacts and unit supply, and about whether city stability should affect the selection projection.
These are not safe changes to make by accident.
They need behaviour tests first.
The Architecture Is Between Two Shapes
The map side of the architecture is now close to the target.
The state and engine are still closer to the old design.
flowchart LR
Legacy["MapData<br/>old boundaries"] --> Adapter["Legacy adapter"]
Adapter --> Map["WorldMap<br/>immutable and indexed"]
Map --> Views["Read-only map views"]
Views --> Rules["Rules, AI,<br/>replay and server"]
OldState["GameState +<br/>PersistentGameState"] -. next .-> State["One DomainState"]
Reducers["Local reducer +<br/>server reducer"] -. next .-> Engine["One GameEngine"]This is an important point.
The repository has not reached the target architecture just because WorldMap exists.
The next parts are larger because they change what the game considers authoritative state and where rules are executed.
What Is Still Missing
One Authoritative Game State
The client still has several related state models.
There is active game state, persistent state, snapshot data, runtime state, and interaction data. Some transitions still copy information manually between them.
The target is one deeply immutable DomainState for game truth.
A GameSnapshot should contain:
- match metadata,
- the domain state,
- the event offset.
Selection, hover state, previews, dialogs, camera movement, renderer transitions, and asset loading should not live inside the authoritative state.
They belong to separate interaction and rendering models.
This migration also needs compatibility tests for existing saves and replays. A cleaner class structure is not a good reason to delete player progress.
Local persistence still needs work too. Reading the latest event offset should use an index instead of a linear scan. Large encoding jobs should run away from the main UI isolate. File operations should be asynchronous.
One Game Engine
Local play and multiplayer still have separate rule execution paths.
They have strong parity tests now, but parity tests protect duplication. They do not remove it.
The next large step is to separate four concepts:
GameIntentfor UI actions,DomainCommandfor attempts to change the game,DomainEventfor accepted domain facts,- server system commands for server-only work.
Selecting a tile, focusing a camera, and opening a preview should not use the same network command hierarchy as moving a unit or ending a turn.
After that split, one GameEngine can apply domain commands to domain state.
Local play, Serverpod, replay tools, and AI should all call that engine.
The command families will move one at a time:
- movement,
- combat,
- cities and production,
- research and diplomacy,
- turn resolution.
Each migration must keep the current local and server behaviour, event output, save compatibility, and performance budget.
The match lifecycle also needs a typed state machine. Protocol versions need a minimum client policy and explicit migrations.
This is probably the most important unfinished part of the refactor.
Real Application Modules
Several large classes still have too many jobs.
The main game notifier handles startup, commands, multiplayer synchronization, snapshots, persistence, and UI effects.
The renderer handles input, camera work, state synchronization, transitions, effects, and lifecycle.
Some large Dart libraries are divided with part files. This makes individual files smaller, but the library is still one large module with shared private state.
These areas need to be split by responsibility.
Serverpod-generated DTOs should stay inside infrastructure adapters. The presentation layer should depend on application ports, not generated server code.
The server reducer, store, and projectors should also be separated by capability.
The goal is not to create more folders.
The goal is to make each part change for one clear reason.
Immutable Delivery and Real Operations
The source release flow is safer now, but the full delivery process is not finished.
The final process should build one set of artifacts from one approved SHA and publish those exact artifacts everywhere.
A release manifest should record:
- the Git SHA,
- version and build number,
- hashes of desktop, mobile, web, and static artifacts,
- the server image digest,
- configuration revision,
- migration revision.
The deployment flow still needs explicit plan, prepare, publish, verify, resume, and rollback stages.
A partial failure should not hide which channels were already published. A resumed release should not create a second version commit.
The backend should be deployed first in a backward-compatible form. Its SHA and image digest should be verified before clients are promoted.
Static sites should use versioned directories and an atomic switch. Rollback should return to the previous manifest instead of rebuilding an unknown checkout.
Monitoring also needs more work:
- correlation IDs,
- structured logs,
- metrics,
- dashboards,
- tested alerts,
- service-level objectives,
- synthetic multiplayer checks.
Backups need encryption, checksums, off-site storage, and automatic restore tests.
A backup is not proven because the file exists.
It is proven when a clean database can be restored from it.
The release path also needs an SBOM, provenance, signatures, secret scanning, container scanning, infrastructure scanning, and a license policy.
One obsolete distribution portal has already been removed. The remaining deploy-all contract still needs to be made explicit and testable.
Shared Tools for the Quality Gates
The project now has several separate governance tools for coverage, architecture, mutation testing, parity, performance, and release checks.
They repeat some basic work:
- strict JSON parsing,
- canonical JSON output,
- hashes,
- Git access,
- policy loading,
- baseline comparison,
- command-line diagnostics.
I do not want the next gate to copy that infrastructure again.
Before adding another large gate, I plan to extract a small shared gate_kit.
It should provide common technical tools while leaving every gate responsible for its own policy.
The goal is reuse, not one universal framework that understands nothing well.
One Deliberate Non-Change
I also reviewed the Serverpod 4.0 beta.
I decided not to add that upgrade to the active refactor.
The new development workflow, embedded PostgreSQL option, SQLite support, and ORM changes are interesting. But this project already has a deterministic bootstrap, an isolated Compose setup, and its own quality gates.
The upgrade would also change internal database structures and the production build format.
That is too much unrelated risk while the map, state, engine, and release architecture are already changing.
For now, the project stays on the pinned stable Serverpod version.
I will review 4.x again after a stable release and after there is a concrete product or operational reason to upgrade.
Not every refactor decision has to add new code.
Sometimes the useful decision is to leave a working dependency alone.
Where the Refactor Stands
It would be easy to count completed tasks and say the refactor is almost done.
That would not be accurate.
The safety work is mostly complete. The quality gates are much stronger. The map migration has removed a large amount of duplicate work. Many rule paths now use one indexed world and small read-only interfaces.
But the largest architectural goals are still ahead:
- one authoritative state,
- one game engine,
- separate UI intents and domain commands,
- smaller application modules,
- immutable artifact delivery,
- tested production recovery.
The repository is already easier to reason about.
A map operation no longer needs to choose between two domain models. Many rules no longer copy the full world to inspect one tile. The AI and server share the same read views. Performance costs are recorded. New full conversions are blocked by architecture tests.
That is real progress.
The most useful measure is still the same question from the original plan:
Does the next feature require fewer unrelated changes than the previous one?
The answer is starting to improve.
The game still works.
The old behaviour is still protected.
And there are fewer places where the same rule can quietly become two different rules.
That is the kind of refactor I wanted.
Leave a Reply