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.
Leave a Reply