[AoNW] Turning Game History Into Something You Can Actually Use

Written by

in

, ,

A strategy game is not only about making decisions.

It is also about understanding them later.

In Age of New Worlds, a turn can contain many tiny things: a scout discovers a new civilization, a city finishes production, a unit moves through fog of war, diplomacy changes slightly, a battle resolves, research progresses, and the player probably notices only half of it while thinking about the next move.

For a long time, that was fine. Events appeared as notifications. Some of them were visible in the HUD. The current turn had enough context to keep playing.

But the more the game grew, the more obvious the problem became:

I was generating useful history, but I was not treating it as a first-class part of the game.

So I started working on two connected features:

  1. a persistent turn/event timeline with filters and charts,
  2. a replay system that can rebuild a full match from the beginning.

This post is about that change.

Not because replay is a flashy feature.

Because replay forced the architecture to answer a very honest question:

Can the game explain what happened?

The Problem With “Recent Events”

The first version of the activity log was simple. When something interesting happened, the game pushed a notification into memory.

That works for a short session.

It does not work for a 4X game.

A 4X match is long. The player may close the app, reopen it tomorrow, inspect diplomacy, wonder why a relation changed, or try to understand why an AI expanded faster. If the history only exists in memory, then it is not really history. It is just a temporary UI effect.

The new activity log is based on the command log.

Every durable command can now carry activity entries:

class LoggedCommand {
  final int offset;
  final DateTime timestamp;
  final int turn;
  final GameCommand command;
  final List<GameEvent> events;
  final List<LoggedActivityEntry> activity;
  final String? actorPlayerId;

  final bool canAct;
  final int commandTick;
  final bool ignoreFogOfWar;
}

That gives the UI a stable source of truth. The activity panel no longer depends only on what happened during the current app session. It can read the full log, paginate it, filter it, and render it again after loading a save.

The important part is not the widget.

The important part is that history moved from presentation memory into application data.

The Turn Timeline

Once events were durable, the next obvious step was to stop showing them only as a list.

Lists are useful for detail, but they are bad at shape.

I wanted to see questions like:

  • which turns were busy?
  • when did combat happen?
  • was the match quiet for ten turns and then suddenly explode?
  • did the player’s science progress happen steadily or in bursts?
  • how much of the current turn is actually eventful?

So the diplomacy/modal turn pill now opens a timeline popup. The same data that powers the event journal can be grouped by turn and category.

flowchart LR
    Command["LoggedCommand"]
    Events["GameEvent[]"]
    Activity["LoggedActivityEntry[]"]
    Projection["Activity History Projection"]
    Timeline["Turn Timeline Popup"]
    Chart["Events Across Turns"]
    Filters["All / Combat / Cities / Science"]

    Command --> Events
    Command --> Activity
    Activity --> Projection
    Projection --> Timeline
    Timeline --> Chart
    Timeline --> Filters

This sounds small, but it changes how the game feels.

A turn-based strategy game has a lot of invisible movement. The timeline makes some of that movement visible.

It also exposed a practical UI issue: empty combat sections could overflow inside the modal. That was a good reminder that data-heavy UI needs boring robustness too. Empty states, pagination, filtered views, and responsive layouts matter as much as the chart itself.

Why Replay Is Not a Video

Replay could mean many things.

The simplest version would be video recording. But that would be huge, platform-specific, and not very useful for analysis.

A better version is deterministic replay:

  1. store the initial state,
  2. store every command,
  3. rebuild every state transition,
  4. ask the renderer to animate each step.

That gives a much more useful result. It is not only “watch what happened”. It is “reconstruct the match”.

The model looks like this:

flowchart TD
    Initial["Replay Initial Snapshot"]
    Log["Command Log"]
    Resolver["LocalCommandResolver"]
    Timeline["ReplayTimeline"]
    Step["ReplayStep"]
    Renderer["Read-only GameRenderer"]

    Initial --> Resolver
    Log --> Resolver
    Resolver --> Timeline
    Timeline --> Step
    Step --> Renderer

The key decision was to introduce a replay seed snapshot.

The normal save snapshot always represents the latest state. That is perfect for loading the game, but wrong for replay. If I only have the latest snapshot, I cannot replay the match from turn one.

So new games now store an immutable initial replay snapshot:

abstract interface class ReplayStore {
  Future<SaveSnapshot?> initialSnapshot(String saveId);

  Future<void> saveInitialSnapshot(
    String saveId,
    SaveSnapshot snapshot,
  );

  Future<void> delete(String saveId);
}

There are two implementations:

  • JSON files for native builds,
  • Sembast/IndexedDB for web builds.

On macOS, for example, replay data lives next to the save:

saves/
  20260606_142303_996/
    snapshot.json
    events.log
    replay_initial_snapshot.json

That separation is important. The current save can move forward. The replay seed stays still.

One Resolver, Two Use Cases

I did not want replay to duplicate game rules.

That would be a trap. Every time turn resolution changed, replay would become subtly wrong.

So I extracted the local command resolution logic into a shared service:

class LocalCommandResolver {
  final GameStateReducer reducer;

  const LocalCommandResolver({required this.reducer});

  LocalCommandResolution resolve({
    required SaveSnapshot baseSnapshot,
    required GameState currentState,
    required GameCommand command,
    required DateTime savedAt,
    GameCommandContext context = const GameCommandContext(),
  }) {
    final effectiveContext = context.copyWith(
      combatSeedTurn: baseSnapshot.save.turn,
      paceBalance: baseSnapshot.save.matchRules.paceBalance,
    );

    final transition = reducer.reduce(
      currentState,
      command,
      context: effectiveContext,
    );

    // submit-turn finalization, combat, economy,
    // diplomacy, movement reset, victory progress...
  }
}

Now normal play and replay go through the same command resolution path.

That gives the architecture a nice property:

flowchart LR
    Player["Player / AI"]
    Transport["LocalCommandTransport"]
    Resolver["LocalCommandResolver"]
    Save["Save Snapshot"]
    EventLog["Event Log"]

    Replay["ReplayService"]
    ReplayStore["Replay Initial Snapshot"]

    Player --> Transport
    Transport --> Resolver
    Resolver --> Save
    Resolver --> EventLog

    ReplayStore --> Replay
    EventLog --> Replay
    Replay --> Resolver

The same reducer path is used when playing and when reconstructing.

That is the kind of boring duplication removal that matters.

Building the Replay Timeline

The replay service is intentionally not a Flutter service. It does not know about widgets. It does not know about Flame. It builds a domain/application timeline.

class ReplayService {
  final ReplayStore replayStore;
  final EventLog eventLog;
  final LocalCommandResolver commandResolver;

  Future<ReplayTimeline> buildTimeline(String saveId) async {
    final initialSnapshot = await replayStore.initialSnapshot(saveId);
    if (initialSnapshot == null) {
      throw ReplayBuildException(
        ReplayBuildFailureReason.missingInitialSnapshot,
        'Replay seed snapshot not found for save: $saveId',
      );
    }

    var currentSave = initialSnapshot.save;
    var currentState = initialSnapshot.toGameState();
    var currentOffset = initialSnapshot.eventLogOffset;
    final steps = <ReplayStep>[];

    await for (final logged in eventLog.readSince(
      saveId,
      offset: currentOffset + 1,
    )) {
      if (logged.offset != currentOffset + 1) {
        throw ReplayBuildException(
          ReplayBuildFailureReason.offsetGap,
          'Replay log has a gap.',
        );
      }

      final previousState = currentState;

      final resolved = commandResolver.resolve(
        baseSnapshot: SaveSnapshot.fromGameState(
          save: currentSave,
          state: currentState,
          eventLogOffset: currentOffset,
        ),
        currentState: currentState,
        command: logged.command,
        savedAt: logged.timestamp,
        context: logged.toCommandContext(),
      );

      currentSave = resolved.save;
      currentState = resolved.state;
      currentOffset = logged.offset;

      steps.add(
        ReplayStep(
          index: steps.length + 1,
          loggedCommand: logged,
          save: currentSave,
          previousState: previousState,
          state: currentState,
          events: logged.events,
          uiEffects: resolved.uiEffects,
        ),
      );
    }

    return ReplayTimeline(
      saveId: saveId,
      initialSnapshot: initialSnapshot,
      initialState: initialSnapshot.toGameState(),
      steps: List.unmodifiable(steps),
    );
  }
}

There are a few deliberate choices here.

First, replay validates offset continuity. If the log jumps from offset 20 to 22, the replay is not trustworthy.

Second, replay stores both previousState and state per step. That gives the renderer enough context for animations.

Third, the service returns data, not UI.

The screen can decide how to present it.

The Replay Screen

The replay screen is a read-only renderer host.

It creates a GameRenderer, but the command callback is a no-op. The player can pan and inspect the map, but replay does not dispatch gameplay commands into the save.

The controls are intentionally simple:

  • play / pause,
  • previous / next,
  • slider,
  • speed,
  • perspective.

The default perspective is “all players”, which disables fog-of-war filtering by using an empty active player id. A player-specific perspective can still be selected from a dropdown.

That gives replay two useful modes:

  • watch the whole match like an analyst,
  • inspect what a specific player could see.
stateDiagram-v2
    [*] --> InitialState
    InitialState --> StepApplied: next
    StepApplied --> Playing: play
    Playing --> StepApplied: timer tick
    Playing --> Paused: pause
    StepApplied --> StepApplied: slider jump
    StepApplied --> InitialState: rewind

Rendering a step is just state plus effects:

final effects = GameRendererEffectSequenceBuilder.build(
  commandEffects: step.uiEffects.rendererEffects,
  events: step.events,
  state: state,
  previousState: previousState,
  l10n: l10n,
);

await renderer.applyTransition(state, effects);

This reuses the existing animation system. Movement, combat, particles, floating text, and event-driven effects can be rebuilt from the replay step.

Again: replay is not a video.

It is the game explaining itself from its own history.

A Small Bug That Proved the Point

One nice thing about this feature is that it immediately exposed assumptions.

My first Load Game condition was too strict:

save.gameMode == GameMode.hotSeat

That looked reasonable because replay was meant for single-player first.

But the newest macOS save already had gameMode: multiplayer while still having complete local replay data:

replay_initial_snapshot.json
events.log
snapshot.json

The event log had 315 commands and continuous offsets from 1 to 315.

So the correct question was not:

Is this save hot-seat?

The correct question was:

Does this save have replay data?

That became a field on the save index:

class GameSaveIndex {
  final GameMode gameMode;
  final bool replayAvailable;
}

And the UI now checks the real availability:

onReplay: save.corrupted || !save.replayAvailable
    ? null
    : () => context.go('/replay?...');

This is a small example, but it is exactly why I like making state explicit.

The UI should not guess architecture from a mode enum when the storage layer can tell it the truth.

Why This Matters for Multiplayer

The current implementation is already useful for local games.

But the bigger reason to do it carefully is multiplayer.

Eventually, when a multiplayer match is finished, I want players to be able to open it from Load Game and watch the whole match from every side.

That means replay cannot be tied to a local widget or a hot-seat assumption. It needs to be based on durable records:

  • initial snapshot,
  • command log,
  • actor context,
  • event projection,
  • renderer effects.

That is also why LoggedCommand now carries more than the command itself:

final String? actorPlayerId;
final bool canAct;
final int commandTick;
final bool ignoreFogOfWar;

AI commands, fog-of-war rules, combat seed turns, and multiplayer authorization can all affect the result. A replay system that ignores that context will work until it suddenly does not.

And when it fails, it will fail in the worst possible way: almost correctly.

Tests

The test coverage landed in the same shape as the architecture.

Replay service tests verify that:

  • a timeline can be rebuilt from an initial snapshot and command log,
  • missing replay seed snapshots are rejected,
  • offset gaps are rejected.

Repository tests verify that:

  • replay initial snapshots are written when creating saves,
  • later saves do not overwrite the replay seed,
  • save indexes expose replayAvailable.

UI tests verify that:

  • playable saves show the replay action,
  • corrupted saves stay unavailable,
  • the replay route opens from Load Game.

The important test is the offset gap test.

await expectLater(
  service.buildTimeline('save_1'),
  throwsA(
    isA<ReplayBuildException>().having(
      (error) => error.reason,
      'reason',
      ReplayBuildFailureReason.offsetGap,
    ),
  ),
);

Replay should not silently invent history.

If the log is incomplete, the game should say so.

What Changed Conceptually

Before this work, events were mostly something the game emitted so the UI could react.

Now they are becoming part of the player’s understanding of the match.

That is a subtle shift.

The game still needs better balance, smarter AI, more content, and many UI improvements. But the foundation is stronger now:

  • commands are durable,
  • events are projectable,
  • activity history survives reloads,
  • timelines can be charted,
  • saves can expose replay availability,
  • matches can be reconstructed from the beginning.

For a 4X game, that matters.

Because strategy is not only about what happens next.

It is also about being able to look back and understand why.

Comments

Leave a Reply

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