[AoNW] From Hotseat to Multiplayer: Making the Server Authoritative

Age of New Worlds started with local play. That was the right place to begin. A 4X game already has enough moving parts before networking enters the picture: turns, units, cities, production, research, fog of war, saves, UI state, and AI. Adding multiplayer too early can turn every feature into a distributed-systems problem.

But I did not want local play to become a dead end.

So even while the game focused on hotseat, I tried to shape the architecture around a future server-backed model. The main rule was simple:

Local play can be convenient, but multiplayer must be authoritative.

Hotseat Is Not Just “Single Player”

Hotseat play is interesting because it already contains some multiplayer pressure.

There are multiple players. They take turns. The active player changes. Some state belongs to the match, some belongs to the current player, and some is only local interaction state.

In hotseat, the client can do everything locally. It can dispatch a command, reduce it, save the snapshot, and move to the next player.

flowchart TD
  UI["Player Input"]
  Command["GameCommand"]
  LocalTransport["LocalCommandTransport"]
  Reducer["GameStateReducer"]
  EventLog["Local Event Log"]
  Save["Local Save Snapshot"]

  UI --> Command
  Command --> LocalTransport
  LocalTransport --> Reducer
  Reducer --> EventLog
  Reducer --> Save

That model is fast and useful. It also let me build the real game loop before the server existed.

But multiplayer changes the ownership of truth.

The Server Must Own Match Truth

In local play, the client is trusted because there is only one runtime.

In multiplayer, the client cannot be trusted to decide the authoritative state. It can request an action, but the server must decide whether that action is valid.

That means the client should not save network matches directly.

In the network repository, this is explicit:

@override
Future<void> save(SaveSnapshot snapshot) {
  throw UnsupportedError(
    'NetworkGameRepository is read-only; dispatch commands to mutate matches',
  );
}

I like this boundary because it makes the architecture honest.

A network client can load projected snapshots. It can send commands. It can receive acknowledgements and live events. But it does not get to write the match state.

Same Port, Different Transport

The client still talks through the same application port:

abstract interface class CommandTransport {
  Future<CommandTransportResult> dispatch({
    required String saveId,
    required GameState currentState,
    required GameCommand command,
    GameCommandContext context = const GameCommandContext(),
  });
}

For local games, this port is implemented by LocalCommandTransport.

For multiplayer games, it is implemented by NetworkCommandTransport.

flowchart TD
  App["Application Use Case"]
  Port["CommandTransport"]
  Local["LocalCommandTransport"]
  Network["NetworkCommandTransport"]
  Server["ServerCommandTransport"]

  App --> Port
  Port --> Local
  Port --> Network
  Network --> Server

This is the benefit of introducing the port early. The presentation layer does not need to know whether the command is handled locally or by the server. It asks the application layer to dispatch intent.

The implementation decides where the truth lives.

Client-Only Commands

Not every command belongs on the server.

Some commands are real game actions:

  • move a unit
  • found a city
  • choose research
  • start production
  • attack
  • submit a turn

Other commands are interaction state:

  • select a tile
  • select a unit
  • open city founding mode
  • start attack targeting
  • focus the next pending action
  • change active local player in hotseat

Those client-only commands should not be sent to the server.

The network transport makes that split explicit:

bool _isClientOnly(GameCommand command) {
  return switch (command) {
    SetActivePlayerCommand() ||
    TileTappedCommand() ||
    CityTappedCommand() ||
    ToggleMoveTargetingCommand() ||
    StartCityFoundingCommand() ||
    CancelCityFoundingCommand() ||
    SelectTileCommand() ||
    SelectUnitCommand() ||
    SelectCityCommand() ||
    FocusNextPendingActionCommand() ||
    FocusTurnStartActionCommand() => true,
    _ => false,
  };
}

This is one of the most important multiplayer boundaries in the project.

The server should not care that a player opened a targeting UI. It should care when the player actually submits an attack command.

End Turn vs Submit Turn

Turn flow is where the difference between hotseat and multiplayer becomes very concrete.

In hotseat mode, ending a turn can immediately move control to the next local human player. It may show a handoff screen and jump the camera.

In multiplayer, ending a turn means submitting my turn to the server. The server waits until the relevant players have submitted, then advances the match.

The application layer chooses the strategy:

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

The hotseat strategy dispatches EndTurnCommand.

The multiplayer strategy dispatches SubmitTurnCommand.

That distinction keeps the domain language clear. Hotseat can advance locally. Multiplayer submits intent and waits for authority.

The Server Command Pipeline

On the server, a command goes through a stricter path.

sequenceDiagram
  participant Client
  participant Route as HTTP Route
  participant Transport as ServerCommandTransport
  participant Auth as Authorizer
  participant Reducer as Server Command Handler
  participant Store as Event/Snapshot Store
  participant Stream as WebSocket Broadcaster

  Client->>Route: POST /matches/:id/commands
  Route->>Transport: dispatch
  Transport->>Auth: validate tick / replay
  Transport->>Store: load latest snapshot
  Transport->>Reducer: reduce command
  Reducer-->>Transport: accepted, state, events
  Transport->>Store: append event
  Transport->>Store: save snapshot
  Transport->>Stream: broadcast projected events
  Transport-->>Client: ack + projected snapshot

The server does more than call the reducer.

It validates the actor. It checks whether the match is running. It checks that the user belongs to the match. It validates the command tick. It prevents stale commands. It stores events and snapshots. It broadcasts events to connected clients.

That is the difference between “remote reducer” and “authoritative server”.

Replay Protection

Network clients retry requests. Connections drop. A command might be submitted twice.

So the server uses client ticks to avoid applying the same command more than once.

Future<ServerCommandTickAuthorization> authorizeReplay({
  required String matchId,
  required String actorPlayerId,
  required int tick,
}) async {
  final events = await readActorTicksAtOrAfter(
    matchId: matchId,
    actorPlayerId: actorPlayerId,
    tick: tick,
  );

  for (final event in events) {
    if (event.tick == tick) {
      return ServerCommandTickAuthorization.duplicate(event);
    }
  }

  if (events.isNotEmpty) {
    return const ServerCommandTickAuthorization.failure(
      code: 'stale_tick',
      statusCode: 409,
    );
  }

  return const ServerCommandTickAuthorization.allowed();
}

If the same tick already exists, the server can return the cached acknowledgement. If a newer tick already exists, the command is stale.

This protects the match from accidental double application.

A unit should not move twice because the client retried a request.

Accepted and Rejected Commands

The server can reject commands without crashing the match.

A rejected command still produces a response. It can also produce a system event explaining why the command was rejected.

That is important because rejection is part of normal multiplayer life. A command may be invalid, stale, client-only, hotseat-only, or submitted after the player already ended their turn.

ServerCommandReduction _reject(
  ServerCommandInput input, {
  required String reason,
}) {
  return ServerCommandReduction(
    accepted: false,
    save: input.snapshot.save,
    state: input.snapshot.state,
    reason: reason,
    events: [SystemEventWire.commandRejected(reason: reason)],
  );
}

I want invalid commands to be boring. They should not corrupt state. They should not require special recovery. They should return a clear acknowledgement and leave the authoritative snapshot intact.

Snapshots as Acknowledgement

When the client sends a network command, it receives an acknowledgement.

That acknowledgement includes:

  • whether the command was accepted
  • the authoritative offset
  • a snapshot
  • visible events
  • an optional rejection reason

On success, the client replaces its state from the returned snapshot:

final ack = WireCommandAck.fromJson(response.requireMap());
final snapshot = snapshotCodec.fromWire(ack.snapshot);

final nextState = snapshot.toGameState(
  activePlayerId: currentState.activePlayerId,
  activePlayerCanAct: _activePlayerCanActAfter(
    currentState: currentState,
    command: command,
    snapshot: snapshot,
  ),
);

This keeps the client aligned with the server.

The client may have local interaction state, but the match state comes back from authority.

Live Events

Submitting commands is only half of multiplayer.

The client also needs to hear about things that happen after other players act. For that, the game uses a websocket stream.

flowchart TD
  Server["Server"]
  EventLog["Event Log"]
  Broadcast["WebSocket Broadcast"]
  ClientA["Player A Client"]
  ClientB["Player B Client"]

  Server --> EventLog
  Server --> Broadcast
  Broadcast --> ClientA
  Broadcast --> ClientB

The live stream can deliver events or a snapshot resync. That gives the client a way to stay current without constantly polling the latest snapshot.

This is where the event log offset becomes useful. A client can subscribe from an offset and continue from there.

Projections Are Not Optional

An authoritative server knows the whole match.

A player should not.

That means multiplayer needs projections. The server stores the full snapshot, then returns a player-specific snapshot.

Fog of war makes this mandatory. If an enemy unit is outside my current vision, the server may know where it is, but my client should not receive it.

flowchart TD
  Full["Authoritative Snapshot"]
  Projector["Snapshot Visibility Projector"]
  Player["Player Id"]
  Fog["Fog of War"]
  Client["Projected Client Snapshot"]

  Full --> Projector
  Player --> Projector
  Fog --> Projector
  Projector --> Client

This is one of the places where DDD and multiplayer meet.

The server model is not the same as the client view model. The server owns truth. The client receives a view of truth shaped by player identity and fog of war.

Why I Did Not Build Multiplayer First

I am glad I did not start with multiplayer.

The game needed a working local loop first. I needed to understand movement, cities, research, turn progression, saves, fog, UI, and rendering before making all of that distributed.

But I am also glad I did not ignore multiplayer until the end.

Because the command transport, event log, snapshots, and server projections were planned early, the move from hotseat to multiplayer is not a total rewrite. It is a change in authority.

Local mode says: reduce here.

Network mode says: request there, then accept the server’s answer.

The Rule I Use

The rule I keep coming back to is:

The client owns interaction. The server owns the match.

That means the client can select, preview, inspect, animate, and guide the player. It can make the game feel immediate.

But once a player tries to change shared game truth, the command must go through authority.

That separation is what lets Age of New Worlds grow from hotseat play toward real multiplayer without throwing away the architecture underneath it.

Comments

Leave a Reply

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