[AoNW] In Age of New Worlds, ending a turn is not a button

It looks like one in the UI, of course. The player presses “end turn”, the game moves forward, and the next decision appears. But architecturally, that click is only the entry point into one of the most important pipelines in the game.

A turn boundary is where delayed systems become real.

Cities grow. Production advances. Research gains points. Workers finish jobs. Settlers found cities. Fog of war changes. Queued movement may continue. Events are emitted. Saves advance. In multiplayer, the player may not even end the whole turn directly; they submit their part and wait for everyone else.

So I try not to think of “end turn” as a UI action.

I think of it as a domain transition.

Hotseat and Multiplayer Are Different

The first complication is that ending a turn does not mean the same thing in every mode.

In hotseat, the active player can end their local turn and the game may hand control to the next player.

In multiplayer, the player submits their turn. The server decides when the match can advance.

That split lives in the application layer:

class EndTurnStrategies {
  static EndTurnStrategy forMode(GameMode gameMode) => switch (gameMode) {
    GameMode.hotSeat => const HotSeatEndTurnStrategy(),
    GameMode.multiplayer => const MultiplayerEndTurnStrategy(),
  };
}

Hotseat dispatches EndTurnCommand.

await dispatch(EndTurnCommand(control.activePlayerId));

Multiplayer dispatches SubmitTurnCommand.

await dispatch(SubmitTurnCommand(control.activePlayerId));

That difference matters. A local game can immediately process the active player’s turn. A multiplayer game has to record that the player is done and wait for authority.

Submit Is Not Advance

Submitting a turn is intentionally smaller than advancing a turn.

static GameStateTransition submitTurn(GameState state, String playerId) {
  if (playerId.isEmpty || state.submittedPlayerIds.contains(playerId)) {
    return GameStateTransition(state: state);
  }

  var next = state.copyWith(
    submittedPlayerIds: {...state.submittedPlayerIds, playerId},
  );

  if (state.activePlayerId == playerId) {
    next = next.copyWith(
      activePlayerCanAct: false,
      moveCommandActive: false,
      movePreview: null,
      cityFoundingDraft: null,
      pendingAction: null,
    );
  }

  return GameStateTransition(state: next);
}

This does not run the whole economy. It does not grow cities. It does not reset the world.

It marks the player as submitted and clears transient interaction state. That is exactly what I want for multiplayer: the player is done acting, but the match may not be ready to advance.

The Turn Pipeline

When the game actually advances a player’s turn, the work is done in phases.

flowchart TD
  End["EndTurnCommand"]
  City["City Processing"]
  Research["Research Processing"]
  Workers["Worker Processing"]
  Founding["City Founding Jobs"]
  Fog["Fog Recompute"]
  Selection["Selection Refresh"]
  Event["TurnEnded Event"]
  Save["Save / Player State Advance"]

  End --> City
  City --> Research
  Research --> Workers
  Workers --> Founding
  Founding --> Fog
  Fog --> Selection
  Selection --> Event
  Event --> Save

The pipeline is explicit:

factory TurnPipeline.playerEndTurn({
  FogOfWarService fogOfWarService = const FogOfWarService(),
}) {
  return TurnPipeline(
    phases: [
      const CityProcessingPhase(),
      const ResearchProcessingPhase(),
      const WorkerProcessingPhase(),
      const CityFoundingProcessingPhase(),
      FogRecomputePhase(fogOfWarService: fogOfWarService),
      const SelectionRefreshPhase(),
      const TurnEndedPhase(),
      const AdvanceTurnPhase(),
    ],
  );
}

I like this shape because turn flow is too important to hide in one giant method.

Each phase has one job. The order is visible. Tests can focus on smaller pieces. Future changes have somewhere to go.

Cities First

City processing advances the economic core of the game.

Cities gain food, may grow, may claim territory, and may advance production queues. Unit upkeep is applied. Gold changes. Buildings and units can complete.

The city phase can emit events like:

  • CityBuiltBuilding
  • CityProducedUnit
  • CityClaimedHex

That means the turn is not just “increment counters”. It is where stored decisions become visible outcomes.

A production choice made several turns ago may become a unit now. A city that has been growing may claim a new hex now. Those outcomes belong to turn processing, not to the original button click that selected production.

Research Uses City Output

Research comes after city processing because cities can generate science.

final result = ResearchTurnProcessor.advanceForPlayer(
  playerId: context.playerId,
  cities: state.cities,
  fieldImprovements: state.fieldImprovements,
  research: state.research,
  mapData: context.mapData,
  bonusScience: context.bonusScience,
);

The phase can emit:

ResearchPointsGainedEvent(...)
TechnologyResearchedEvent(...)

This makes the economy and technology systems connected without merging them into one object. Cities produce science. Research consumes it. The turn pipeline coordinates the handoff.

Workers and Founding Jobs

Workers also advance during the turn.

A worker job may complete and create or update a field improvement. That can change city yields later. It can also affect events and player feedback.

City founding jobs are processed too:

CityFoundingJobProcessor.advanceForPlayer(
  playerId: context.playerId,
  units: state.units,
  cities: state.cities,
  mapData: context.mapData,
  countryForPlayer: state.countryForPlayer,
);

This is why founding a city was modeled as a job. Confirming founding commits the unit. The turn pipeline turns that job into an actual GameCity.

The turn boundary is where intentions mature into world state.

Fog Must Be Recomputed

After cities, units, workers, and founding jobs change the world, fog of war may no longer be correct.

So the pipeline recomputes it:

final fogOfWar = fogOfWarService.recompute(
  current: state.fogOfWar,
  mapData: context.mapData,
  playerIds: knownPlayerIds(state),
  units: state.units,
  cities: state.cities,
);

This is another reason ending a turn is not just UI. A new city may reveal territory. A moved or removed unit may change vision. A worker or production result may alter what the player should know.

Fog is downstream of the turn.

Selection Needs Refreshing

The selected object may have changed during the turn.

A selected settler might become a city. A selected city might grow. A selected field improvement might be completed or removed. The UI should not keep pointing at stale objects.

That is why there is a selection refresh phase.

If a selected founding unit disappears and a city appears at its location, selection can move to the new city. If an object no longer exists, selection can be cleared.

This is a small UX detail, but it belongs in the turn pipeline because the turn pipeline is where the world changes all at once.

Save State Advances Separately

There is also the save-level turn state.

GameSave tracks turn number and player turn states:

GameSave withPlayerFinished(String playerId) {
  final updated = Map<String, PlayerTurnState>.from(playerStates)
    ..[playerId] = PlayerTurnState.finished;

  final allDone = updated.values.every(
    (s) => s == PlayerTurnState.finished,
  );

  if (allDone) {
    return copyWith(playerStates: updated).withNewTurn();
  }

  return copyWith(playerStates: updated);
}

When all players are finished, the save opens a new turn:

GameSave withNewTurn() {
  final reset = playerStates.map(
    (k, _) => MapEntry(k, PlayerTurnState.active),
  );

  return copyWith(
    turn: turn + 1,
    playerStates: reset,
  );
}

This is the persistent match timeline. The domain state changes what happened. The save state records where the match is in turn order.

The “Next Action” System

Ending a turn also depends on knowing whether the player is actually done.

The game can find pending manual actions:

  • units that still need orders
  • cities without production
  • missing research selection
static List<_PendingTurnAction> _pendingTurnActions(
  GameState state,
  String playerId,
  MapData mapData,
  TechnologyRuleset technologyRuleset,
) {
  // units, city production, research
}

This powers the “next action” flow. Instead of forcing the player to scan the entire empire manually, the game can focus the next thing that needs attention.

That is part of turn flow too. A good turn system is not only about processing the end. It is also about helping the player reach the end without losing track.

Simultaneous Turns Add Combat

The multiplayer/simultaneous pipeline has one extra phase at the front:

factory TurnPipeline.simultaneousTurn({
  FogOfWarService fogOfWarService = const FogOfWarService(),
}) {
  return TurnPipeline(
    phases: [
      const CombatResolutionPhase(),
      const CityProcessingPhase(),
      const ResearchProcessingPhase(),
      const WorkerProcessingPhase(),
      const CityFoundingProcessingPhase(),
      FogRecomputePhase(fogOfWarService: fogOfWarService),
      const SelectionRefreshPhase(),
      const TurnEndedPhase(),
      const AdvanceTurnPhase(),
    ],
  );
}

In simultaneous turns, attacks can be declared as intentions and resolved at the turn boundary. That keeps multiplayer from becoming a race of who clicked first.

Again, the turn boundary becomes a rules engine, not a button callback.

Why This Matters

The turn system connects almost every major subsystem:

flowchart LR
  Movement["Movement"]
  Cities["Cities"]
  Production["Production"]
  Research["Research"]
  Workers["Workers"]
  Founding["City Founding"]
  Combat["Combat"]
  Fog["Fog of War"]
  Save["Save State"]
  UI["Selection / HUD"]

  Movement --> Save
  Cities --> Production
  Production --> UI
  Cities --> Research
  Workers --> Cities
  Founding --> Cities
  Combat --> Movement
  Cities --> Fog
  Movement --> Fog
  Fog --> UI
  Save --> UI

If ending a turn were just a button, all of this logic would end up scattered across UI handlers, reducers, repositories, and render callbacks.

Instead, the architecture gives turn flow a home.

The Rule I Use

The rule is:

A turn ends in the UI, but advances in the domain.

The button only expresses intent. The turn pipeline does the work.

That distinction keeps Age of New Worlds from hiding critical game logic inside presentation code. It also gives every delayed system a clear place to resolve: cities, research, workers, founding, fog, combat, saves, events, and player handoff.

In a 4X game, the turn boundary is where the empire breathes.

Comments

Leave a Reply

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