When I started building AoNW, it was tempting to let the map renderer become the center of the game.
That would have been natural. The renderer is where the player clicks. It knows which hex is under the pointer. It owns the camera, visual layers, hover states, unit markers, movement previews, fog overlays, particles, and the general feeling of interacting with the world.
But in a 4X game, that is exactly why the renderer should not own the game.
A click on a hex is not just a click on a hex. Depending on the current state, it might mean “select this tile”, “move this unit”, “inspect this terrain”, “choose this city expansion hex”, “confirm a worker action”, “target an attack”, or “ignore this because the active player cannot see it”.
If the renderer starts deciding all of that, the game rules slowly leak into the visual layer. At first that feels fast. Later it becomes painful. Save/load, AI, hotseat turns, multiplayer, fog of war, and tests all start asking the same question: where does the truth actually live?
For Age of New Worlds, the answer is: not in the renderer.
Input Is Not Game Logic
The renderer’s job is to turn player interaction into intent.
It can know that the player tapped a hex. It can know which unit sprite was clicked. It can know that a long press should preview inspection. It can animate movement, update camera focus, and display overlays.
But it should not be the authority that mutates the world.
Instead, the renderer sends commands.
sealed class GameCommand {
const GameCommand();
}
Every meaningful player action is represented as a command: selecting a tile, moving a unit, founding a city, ending a turn, choosing research, assigning a worker, attacking, and so on.
That gives the rest of the architecture a stable language. The UI does not need to know whether the game is local, hotseat, or eventually server-backed. It sends a command and waits for the resulting state.
The Command Transport Boundary
The command boundary is expressed through a transport interface:
abstract interface class CommandTransport {
Future<CommandTransportResult> dispatch({
required String saveId,
required GameState currentState,
required GameCommand command,
GameCommandContext context = const GameCommandContext(),
});
}
I like this boundary because it is small but important.
The presentation layer does not need to know how a command is handled. In local play, the command can be reduced immediately and persisted to disk. In multiplayer, the same idea can go through the server. For tests, I can exercise reducers and transports without needing a running renderer.
That is the real architectural win: the renderer becomes one possible source of commands, not the owner of the game.
Reducers Own the Rules
Once a command reaches the domain layer, the reducer decides what it means.
A simplified version of the reducer shape looks like this:
class GameStateReducer {
GameStateTransition reduce(
GameState state,
GameCommand command, {
GameCommandContext context = const GameCommandContext(),
}) {
return switch (command) {
TileTappedCommand() => _handleTileTapped(state, command, context),
MoveUnitCommand() => MovementReducer.moveUnit(
state,
command,
mapData,
context: context,
),
FoundCityCommand() => CityFoundingReducer.confirmCityFounding(
state,
mapData,
command: command,
context: context,
cityRuleset: ruleset.city,
),
EndTurnCommand(:final playerId) =>
TurnReducer.advanceCitiesForPlayer(
state,
playerId,
mapData,
cityRuleset: ruleset.city,
technologyRuleset: ruleset.technology,
paceBalance: context.paceBalance,
),
_ => GameStateTransition(state: state),
};
}
}
The exact reducer is larger than this, because the game already has movement, combat, research, city production, workers, selection state, and turn flow. But the idea stays the same: commands enter, game state exits.
The renderer does not decide whether a unit can move. It asks by sending a command. The reducer checks the state, map, ruleset, visibility, ownership, pending action, and context.
That matters a lot for a strategy game. The same rules must apply whether the command came from a mouse click, a touch gesture, a keyboard shortcut, an AI routine, a restored save, or a network message.
A Tile Tap Can Mean Many Things
One of the clearest examples is tapping a tile.
In a simple game, TileTappedCommand might only select a tile. In Age of New Worlds, it has to respect the current interaction mode.
If the player is choosing city territory, the tap can toggle a controlled hex. If the player is assigning a worker action, the tap can become a movement or improvement decision. If attack targeting is active, the tap can become an attack target. If research selection is open, the tap may only inspect the map.
That logic belongs near the game state, not inside a Flame component.
The reducer can ask questions like:
- Is there a pending action?
- Can the active player inspect this tile?
- Does the selected unit belong to the current actor?
- Is this tap part of movement targeting?
- Should this produce UI effects, game events, or only state?
The renderer should not have to know all of that. It should only know how to make the result visible and responsive.
Local Today, Network Tomorrow
At the moment, Age of New Worlds supports local play and hotseat-focused flows. But the architecture is already shaped around command/event transport.
The local transport does more than just call the reducer. It loads the save, builds the effective command context, applies the reducer, appends the command and resulting events to an event log, saves the new snapshot, and occasionally stores a durable snapshot.
That means local play is not treated as a throwaway prototype path. It follows the same model I want multiplayer to follow later.
final transition = reducer.reduce(
currentState,
command,
context: effectiveContext,
);
await eventLog.append(
saveId,
LoggedCommand(
offset: offset,
timestamp: timestamp,
actorPlayerId: effectiveContext.actorPlayerId,
command: command,
events: events,
),
);
This is one of those choices that costs a little more early, but pays back when the game grows. I do not want to rewrite the core turn model just because a command starts coming from a server instead of a local tap.
What the Renderer Still Owns
None of this makes the renderer unimportant. The renderer owns a lot.
It owns the visual world hierarchy. It owns map layers, camera behavior, unit animation, particles, hover feedback, movement previews, fog overlays, and the feeling of playing the game.
In the current client, the renderer receives an onCommand callback:
GameRenderer(
mapData: session.mapData,
imagePath: session.imagePath,
initialCamera: session.initialCamera,
onCommand: _dispatchRendererCommand,
l10n: widget.l10n,
);
That is the relationship I want. The renderer can say: “the player intended this.” The application layer can say: “I will dispatch that.” The domain layer can say: “given the current rules, this is what actually happened.”
Then the renderer gets the new state and turns it into something the player can understand.
Why This Matters for AI and Tests
This command/reducer split also makes AI work less strange.
An AI player should not need to click the UI. It should produce the same kind of commands a human player produces. If the AI wants to move a unit, choose research, found a city, or end a turn, it should go through the same rule path.
The same applies to tests. I can test movement rules, city production, combat, fog of war, and turn flow without rendering a frame. That is important because 4X games have many small rules that interact in surprising ways.
The renderer can have its own tests for visual behavior and interaction contracts, but the core rules need to survive without it.
The Trade-Off
This architecture is not free.
There is more code between a tap and a result. There are commands, use cases, transports, reducers, transitions, event logs, snapshots, and providers. For a tiny prototype, that can look excessive.
But AoNW stopped being a tiny prototype the moment systems started overlapping.
Movement touches visibility. Visibility touches selection. Selection touches HUD state. HUD state touches objectives. Objectives touch turn flow. Turn flow touches economy, research, production, combat, saves, and eventually networking.
At that point, the extra structure is not ceremony. It is a way to keep the game understandable.
The Rule I Keep Coming Back To
The renderer may be the player’s window into the world, but it is not the world.
It can make the game feel alive. It can make a unit slide across the map, a city pulse with production, a fog layer hide unknown territory, and a selected tile feel tactile.
But the truth of the game belongs somewhere else.
For Age of New Worlds, that truth lives in commands, reducers, state transitions, and the systems around them. That choice is shaping almost every part of the project: local play, save files, AI, tests, and the path toward multiplayer.
The next challenge is making sure that this clean architectural idea still holds when the map becomes visually rich, the HUD becomes more helpful, and the player expects every action to feel immediate.
Leave a Reply