Saving a sounds simple until the game starts becoming a real 4X game.At the beginning, it is tempting to think of saving as one operation:
jsonEncode(gameState)
That works for a prototype. It does not work for long.
Age of New Worlds has units, cities, field improvements, player gold, research, fog of war, turn state, pending actions, camera position, match rules, hotseat flow, multiplayer flow, and event offsets. Some of that is durable game truth. Some of it is client interaction state. Some of it should survive forever. Some of it should be projected differently per player.
So the save system became one of the places where the architecture had to become more explicit.
The Problem with “Just Save the State”
The main issue is that not all state is the same kind of state.
There is the state of the world: units, cities, research, fog of war, field improvements.
There is match metadata: map name, players, turn number, game mode, rules.
There is runtime state: submitted players, pending attacks, city founding drafts, turn timers.
There is client state: active player selection, UI-focused pending action, camera position, local interaction mode.
If all of that is stored as one object, it becomes hard to answer basic questions:
- What should be persisted?
- What should be reconstructed on load?
- What belongs to the authoritative server?
- What is only local UI state?
- What must be visible to every player?
- What must be projected through fog of war?
So I split the save model into a few concepts.
GameSave, GameState, and SaveSnapshot
The client has an active GameState, but the save system does not treat that as the only durable object.
A snapshot combines match metadata and persistent world state:
class SaveSnapshot {
final GameSave save;
final Map<String, int> playerGold;
final List<GameUnit> units;
final List<GameCity> cities;
final List<FieldImprovement> fieldImprovements;
final FogOfWarState fogOfWar;
final ResearchState research;
final GameRuntimeState runtimeState;
final int eventLogOffset;
}
GameSave describes the match: players, map, turn, rules, game mode, and save metadata.
PersistentGameState describes the durable world.
GameState is the active runtime model used by the client.
SaveSnapshot is the bridge between them.
flowchart TD Save["GameSave\nmatch metadata"] Persistent["PersistentGameState\nworld state"] Snapshot["SaveSnapshot\nsave + state + offset"] Runtime["GameState\nactive client state"] Save --> Snapshot Persistent --> Snapshot Snapshot --> Runtime Runtime --> Snapshot
That bridge matters because loading a game is not only deserialization. It is also reactivating the game for a specific player and mode.
Event Log Offset
Every snapshot carries an eventLogOffset.
That number tells the client which command/event offset the snapshot represents.
class SaveSnapshot {
final int eventLogOffset;
}
This is small, but important.
It lets the game say: this snapshot is current up to event 57. If a client connects later, the server or local system can reason about events after that point. If a websocket stream resumes, it can ask for events since the last known offset.
The snapshot is not floating in time. It is anchored to the command/event history.
Commands, Events, and Snapshots
When a command is dispatched locally, the transport does several things:
- Load the current save snapshot.
- Build the command context.
- Reduce the command into a new state.
- Append the command and events to the event log.
- Save the new snapshot.
- Optionally store a durable checkpoint snapshot.
Simplified, the flow looks like this:
final transition = reducer.reduce(
currentState,
command,
context: effectiveContext,
);
await eventLog.append(
saveId,
LoggedCommand(
offset: offset,
timestamp: timestamp,
actorPlayerId: effectiveContext.actorPlayerId,
command: command,
events: events,
),
);
final snapshot = SaveSnapshot.fromGameState(
save: resolved.save,
state: resolved.state,
eventLogOffset: offset,
);
await gameRepository.save(snapshot);
This is not full event sourcing.
I am not rebuilding the entire game from event zero every time. That would be possible in theory, but it would make iteration slower and persistence more fragile while the game is still changing quickly.
Instead, Age of New Worlds uses a practical hybrid:
flowchart LR Command["Command"] Reducer["Reducer"] Events["Event Log"] Snapshot["Current Snapshot"] Checkpoint["Stored Snapshot"] Command --> Reducer Reducer --> Events Reducer --> Snapshot Snapshot --> Checkpoint
Commands and events give me history, debugging, offsets, multiplayer synchronization, and live updates.
Snapshots give me fast load, simpler persistence, and stable recovery.
What Gets Saved
The durable state includes things that describe the actual match:
- players and turn metadata
- player colors and countries
- gold
- units
- cities
- field improvements
- fog of war
- research
- runtime match state
- event log offset
The current Flutter screen, open HUD panel, hover state, animations, and renderer internals do not belong in the save.
Some client-facing values are still persisted when they are useful, like camera state. But even that is treated as part of the save boundary, not as a reason for the domain to know about Flutter or Flame.
Runtime State Is Tricky
GameRuntimeState is one of the more interesting parts of the save model.
Some runtime data is real game state. For example, submitted players in simultaneous turns must persist. Intended attacks must persist. Domination hold turns must persist.
Other runtime data is more like interaction state. A pending city founding draft or pending action may be useful locally, but it is not always appropriate to store or broadcast as server-authoritative state.
This is why the server has a projection step that removes client interaction state from the authoritative snapshot:
PersistentGameState withoutClientInteractionState() {
return copyWith(
runtimeState: runtimeState.withoutClientInteractionState(),
);
}
That line represents an important rule:
The server owns match truth, not the client’s temporary interaction flow.
Network Saves Are Read-Only on the Client
For local games, the client can save directly.
For network games, the client does not write snapshots directly. It sends commands. The server validates, reduces, stores the event, stores the snapshot, and returns an acknowledgement.
That difference is visible in the network repository:
Future<void> save(SaveSnapshot snapshot) {
throw UnsupportedError(
'NetworkGameRepository is read-only; dispatch commands to mutate matches',
);
}
I like this because it makes authority explicit.
In multiplayer, saving is a server responsibility. The client receives projected snapshots, but it does not get to decide the authoritative state of the match.
Resync and Live Events
The multiplayer model also uses snapshots for resync.
The client can subscribe to live events over websocket. The server may send event messages or a snapshot message if the client needs to resynchronize.
sequenceDiagram participant Client participant Server participant DB Client->>Server: subscribe since offset Server-->>Client: ready Server-->>Client: event offset 58 Server-->>Client: event offset 59 Server-->>Client: snapshot resync Client->>Client: replace local state from snapshot
That means snapshots are not only for loading from the main menu. They are also a recovery tool for live multiplayer.
If the client misses something, the server can send the current projected snapshot and the client can continue from a known state.
Save Migrations
A game in active development changes its save format constantly.
New fields appear. Old fields disappear. Fog of war changes shape. Research state changes. Runtime state grows. Match rules evolve.
If I want to keep old saves loadable, deserialization cannot assume that every save was written by today’s code.
That is why the save codec runs migrations:
final persistentState = PersistentGameState.fromJson({
'playerColors': rawPlayerColors,
'playerCountries': rawPlayerCountries,
'playerGold': rawPlayerGold,
'units': rawUnits,
'cities': rawCities,
'fieldImprovements': rawFieldImprovements,
'fogOfWar': GameFogOfWarStateMigrator.migrate(json['fogOfWar']),
'research': GameResearchStateMigrator.migrate(value),
'runtimeState': GameRuntimeStateMigrator.migrate(value),
});
Migrations are not glamorous, but they are essential.
They let the project keep moving without deleting every old save after every refactor. They also force me to think about save compatibility as part of the architecture, not as a cleanup task.
Projection Before Persistence Becomes UI
In local play, the snapshot can mostly represent the full game state.
In multiplayer, the server has to project snapshots per player. A player should not receive hidden enemy units, other players’ private research state, or runtime actions that belong to someone else.
So the server stores the authoritative snapshot, then projects it for the requesting player.
flowchart TD Full["Authoritative Snapshot"] Projector["Visibility Projector"] Fog["Fog of War"] Player["Player Id"] Client["Projected Snapshot"] Full --> Projector Fog --> Projector Player --> Projector Projector --> Client
This is where save/load touches fog of war and multiplayer.
A save is not only “what exists”. For a client, a save is also “what this player is allowed to know”.
That changes how I think about persistence. The database can store truth. The client should receive a view.
Why This Is Not Full Event Sourcing
I considered the event-sourcing shape because the command/event model naturally points in that direction.
But for this project, full event sourcing would be too expensive right now.
The game is still changing. Rules evolve. Event payloads evolve. Balance changes. AI changes. If every load depended on replaying every historical event through today’s reducers, old saves would become extremely fragile.
So I use events where they are useful:
- logging commands
- showing activity
- debugging
- multiplayer offsets
- live synchronization
- visibility filtering
- replay protection
And I use snapshots where they are useful:
- fast loading
- durable current state
- resync
- migrations
- server authority
It is a compromise, but a deliberate one.
The Rule I Use
The rule I keep coming back to is:
A save file should contain durable game truth, not the current shape of the UI.
That sounds simple, but it affects many choices.
The renderer does not get saved. The HUD does not get saved. Temporary interaction modes should be treated carefully. Server snapshots should not preserve client-only state. Multiplayer clients should receive projected snapshots. Old data should pass through migrations before becoming active state.
Saving a 4X game is not just persistence. It is an architectural boundary.
It decides what the game believes is real, what can be reconstructed, what belongs to one player, and what must survive the next version of the code.
Leave a Reply