An authoritative server knows the whole match.
That sounds obvious, but it creates one of the most important architectural questions in Age of New Worlds: if the server knows everything, what exactly should each client receive?
In a local game, this question is mostly about UI. In multiplayer, it becomes a data boundary.
A player should not receive hidden enemy units and trust the client to simply not render them. The client should not know private research state, hidden production queues, or runtime actions that belong to another player. Fog of war should not be only a visual effect. It should shape the data.
So the server does not send the full truth to everyone.
It sends projections.
Truth vs View
I think about multiplayer state in two layers:
flowchart TD Full["Authoritative State\nserver knows everything"] Projection["Player Projection\nfiltered by identity and fog"] Client["Client State\nwhat the player can know and use"] Full --> Projection Projection --> Client
The authoritative state is the match truth. It contains all units, all cities, all research, all runtime state, all fog, and all players.
The projected state is a player-specific view. It contains only the data that player should be allowed to know.
That distinction is important because hiding things in the renderer is not enough. If the data reaches the client, then the client knows it.
Snapshot Projection
The server stores full snapshots, but clients load projected snapshots.
The projection step takes a stored match snapshot and a player id, then returns a filtered snapshot:
class SnapshotVisibilityProjector {
StoredMatchSnapshot project({
required StoredMatchSnapshot snapshot,
required String playerId,
}) {
if (playerId.isEmpty) return snapshot;
final state = _parseState(snapshot.state);
if (state == null) return snapshot;
final projectedState = _projectState(state, playerId);
return StoredMatchSnapshot(
matchId: snapshot.matchId,
offset: snapshot.offset,
save: snapshot.save,
state: projectedState.toJson(),
createdAt: snapshot.createdAt,
);
}
}
The projector is not part of the renderer. It lives on the server side because this is not a visual concern. It is part of multiplayer authority.
What Gets Filtered
The projection filters several kinds of data:
Authoritative State
player gold
units
cities
field improvements
fog of war
research
runtime state
A player receives their own gold, but not everyone else’s. They receive their own research, but not everyone else’s. They receive their own fog state, not the full fog state for all players.
Units and cities are filtered by ownership and visibility:
units: [
for (final unit in state.units)
if (_owns(playerId, unit.ownerPlayerId) ||
_isVisible(state, playerId, unit.col, unit.row))
_projectUnit(unit, playerId),
],
That means I always see my own units. I only see enemy units if they are currently visible through fog of war.
The same idea applies to cities:
cities: [
for (final city in state.cities)
if (_owns(playerId, city.ownerPlayerId) ||
_isVisible(state, playerId, city.center.col, city.center.row))
_projectCity(city, playerId),
],
A city I own is fully available. An enemy city must be visible before it appears in my snapshot.
Private Details
Projection is not only about including or excluding objects. Sometimes the object is visible, but some details are private.
For example, an enemy city may be visible, but that does not mean I should receive its production queue:
GameCity _projectCity(GameCity city, String playerId) {
if (_owns(playerId, city.ownerPlayerId)) return city;
return city.copyWith(productionQueue: null);
}
The same applies to enemy units. If I can see an enemy unit, I may need enough data to render and interact with it, but not its queued path or worker assignment:
GameUnit _projectUnit(GameUnit unit, String playerId) {
if (_owns(playerId, unit.ownerPlayerId)) return unit;
return unit
.copyWithQueuedPath(null)
.copyWithWorkerJob(null)
.copyWithWorkerAssignment(null);
}
This is a useful middle ground. Visibility does not automatically mean total transparency.
Fog as a Data Boundary
Fog of war gives the projector its main rule.
bool _isVisible(
PersistentGameState state,
String playerId,
int col,
int row,
) {
return state.fogOfWar.isVisible(
playerId,
HexCoordinate(col: col, row: row),
);
}
This is why fog belongs in the domain model and not only in the Flame overlay.
The fog overlay answers: how should hidden knowledge look?
The projection answers: should this knowledge be sent at all?
Those are different questions.
Runtime State
Runtime state also needs filtering.
A city founding draft or pending action belongs to a specific player. If I am founding a city, my client needs that draft. Another player should not receive it.
GameRuntimeState _projectRuntimeState(
GameRuntimeState runtimeState,
String playerId,
) {
final draft = runtimeState.cityFoundingDraft;
final pending = runtimeState.pendingAction;
return GameRuntimeState(
cityFoundingDraft: draft?.ownerPlayerId == playerId ? draft : null,
pendingAction: pending?.ownerPlayerId == playerId ? pending : null,
submittedPlayerIds: runtimeState.submittedPlayerIds,
intendedAttacks: [
for (final attack in runtimeState.intendedAttacks)
if (attack.declaringPlayerId == playerId) attack,
],
turnStartedAt: runtimeState.turnStartedAt,
);
}
This keeps client interaction state from leaking across players while still preserving shared match facts like submitted turns.
Events Need Projection Too
Snapshots are only half of the problem.
Multiplayer also streams events. If another player moves a hidden unit, I should not receive a detailed UnitMoved event for it.
That is why the server has an event visibility filter:
abstract interface class EventVisibilityFilter {
Map<String, Object?>? filter({
required String? playerId,
required StoredMatchEvent event,
Map<String, dynamic>? state,
});
}
The filter can return:
- the event envelope as-is
- a modified envelope
null, meaning the player should not receive it
For example, a rival’s command can be hidden while visible resulting events are still delivered:
Enemy command:
hidden from me
Visible result:
"enemy unit attacked my unit"
This is an important distinction. I may be allowed to know that combat happened near me, but not the full command history of another player.
The Design Rule
The rule I use is:
The server stores truth. The client receives permissioned knowledge.
That applies to snapshots, events, fog of war, research, production queues, runtime actions, and future multiplayer UI.
This makes the server code more complex. There is no way around that. But the payoff is worth it: the client becomes simpler to trust because it never receives data it should not know.
For a 4X game, that is not just security. It is game design.
Exploration only matters if hidden things are actually hidden.
Leave a Reply