A 4X has many different kinds of decisions. Some are game rules. Some are player workflow. Some are persistence. Some are rendering. Some are network concerns. If I let all of them live in the same place, every new feature becomes harder than it should be.
So the project uses a DDD-inspired split between the domain layer and the application layer.
The domain layer answers: what is true in the game?
The application layer answers: how do I perform a use case around that truth?
The Simplified Shape
The client game code is roughly organized like this:
lib/game/
domain/
game_state.dart
game_save.dart
reducer/
game_state_reducer.dart
movement_reducer.dart
turn_reducer.dart
combat_reducer.dart
city_production_reducer.dart
research_reducer.dart
movement/
hex_assessment/
city.dart
application/
ports/
command_transport.dart
game_repository.dart
event_log.dart
snapshot_store.dart
clock.dart
id_generator.dart
use_cases/
dispatch_command_use_case.dart
bootstrap_game_state_use_case.dart
end_turn_use_case.dart
services/
end_turn_strategy.dart
player_control_coordinator.dart
game_session.dart
infrastructure/
persistence/
transport/
system/
presentation/
screens/
widgets/
providers/
engine/
This is not “clean architecture cosplay”. The split exists because the game has multiple runtimes:
- local hotseat play
- future/server-backed multiplayer
- AI turns
- save/load
- map rendering
- Flutter HUD workflows
- tests and balance tools
All of those need to talk about the same game, but they should not all own the same responsibilities.
The Layer Diagram
flowchart TD Presentation["Presentation: Flutter, Riverpod, Flame"] Application["Application: use cases, services, ports"] Domain["Domain: state, reducers, rules"] Infrastructure["Infrastructure: JSON, HTTP, WebSocket, system adapters"] Core["aonw_core: shared commands, events, rules"] Presentation --> Application Application --> Domain Application --> Core Domain --> Core Infrastructure --> Application
The arrows matter.
The domain should not know about Flutter widgets, Riverpod providers, HTTP clients, PostgreSQL, WebSockets, local files, or Flame components.
The application layer may coordinate work, but it should depend on ports rather than concrete storage or network code.
Infrastructure implements those ports.
Presentation starts use cases and renders results.
What Belongs in the Domain Layer
The domain layer contains the rules and state transitions of the game.
This is where I put things like:
- movement rules
- selection rules
- combat resolution
- city founding
- production
- research
- worker actions
- turn progression
- fog-aware interaction rules
- score and objective-related state
- game state transitions
The central idea is that a command enters the domain and a new state comes out.
class GameStateReducer {
GameStateTransition reduce(
GameState state,
GameCommand command, {
GameCommandContext context = const GameCommandContext(),
}) {
return switch (command) {
MoveUnitCommand() => MovementReducer.moveUnit(
state,
command,
mapData,
context: context,
),
FoundCityCommand() => CityFoundingReducer.confirmCityFounding(
state,
mapData,
command: command,
context: context,
cityRuleset: ruleset.city,
),
SelectTechnologyCommand() => ResearchReducer.selectTechnology(
state,
command,
context: context,
mapData: mapData,
ruleset: ruleset.technology,
),
_ => GameStateTransition(state: state),
};
}
}
The reducer does not save files. It does not call HTTP. It does not render animation. It does not navigate screens.
It only answers: given this state, this command, this map, and this ruleset, what should the game state become?
Domain Transitions
Reducers return a transition:
class GameStateTransition {
final GameState state;
final List<GameEvent> events;
final List<UiEffect> uiEffects;
const GameStateTransition({
required this.state,
this.events = const [],
this.uiEffects = const [],
});
}
This is a useful compromise.
The domain can say: “this command moved a unit, produced these events, and should cause an abstract visual effect.” But it does not play the animation itself.
A reducer may return an AnimateUnitMoveEffect, but the renderer decides how to animate it. A reducer may return a GameEvent, but the event log decides how to persist it. That keeps the domain expressive without making it depend on UI or storage.
What Belongs in the Application Layer
The application layer is where use cases live.
It does not decide the rules of movement or combat. Instead, it coordinates things around the domain:
- loading a save
- bootstrapping game state
- dispatching a command
- choosing the correct end-turn strategy
- asking a repository to reload state
- deciding whether hotseat or multiplayer flow applies
- converting snapshots into active game state
- calling ports such as repositories, transports, logs, and clocks
For example, bootstrapping a game is not a pure domain rule. It is an application workflow:
class BootstrapGameStateUseCase {
final GameRepository repository;
final DispatchCommandUseCase dispatchCommand;
Future<BootstrapGameStateResult> executeWithResult({
required String saveId,
String? preferredPlayerId,
}) async {
final snapshot = await repository.load(saveId);
final control = PlayerControlCoordinator.initialForPlayer(
save: snapshot.save,
preferredPlayerId: preferredPlayerId,
);
final initialState = snapshot.toGameState(
activePlayerId: control.activePlayerId,
activePlayerCanAct: control.canAct,
);
final result = await dispatchCommand.execute(
saveId: saveId,
currentState: initialState,
command: SetActivePlayerCommand(
control.activePlayerId,
canAct: control.canAct,
),
);
return BootstrapGameStateResult(
state: result.state,
offset: result.offset,
);
}
}
This code is not a game rule. It is orchestration.
It asks the repository for a snapshot, chooses the active player, converts durable state into runtime state, and dispatches an initial command.
That belongs in application code.
Ports Define the Boundary
The application layer talks through ports.
abstract interface class GameRepository {
Future<String> create(NewGameRequest request);
Future<List<GameSaveIndex>> list();
Future<SaveSnapshot> load(String saveId);
Future<void> save(SaveSnapshot snapshot);
Future<void> delete(String saveId);
}
abstract interface class CommandTransport {
Future<CommandTransportResult> dispatch({
required String saveId,
required GameState currentState,
required GameCommand command,
GameCommandContext context = const GameCommandContext(),
});
}
These ports are important because local play and network play are different infrastructure choices, not different game architectures.
A local repository can read JSON. A network repository can call HTTP. A local command transport can reduce commands in-process. A network command transport can send commands to the server.
The application layer does not need to care which one is active.
End Turn as a Boundary Example
Ending a turn sounds like a domain rule, but the full end-turn workflow is not only domain logic.
In hotseat, ending a turn may advance to another local human player and trigger a handoff screen. In multiplayer, ending a turn submits the player’s turn and waits for the server.
That difference belongs in the application layer:
abstract interface class EndTurnStrategy {
Future<EndTurnResult> endTurn({
required GameSave save,
required PlayerControlState control,
required DispatchGameCommand dispatch,
required ReloadGameSave reloadSave,
});
}
The strategy can differ by game mode:
class EndTurnStrategies {
static EndTurnStrategy forMode(GameMode gameMode) => switch (gameMode) {
GameMode.hotSeat => const HotSeatEndTurnStrategy(),
GameMode.multiplayer => const MultiplayerEndTurnStrategy(),
};
}
Hotseat dispatches an EndTurnCommand. Multiplayer dispatches a SubmitTurnCommand.
That is an application decision. The actual effect of those commands still belongs to the domain or server-side command handling.
The Boundary Rule
The rule I try to follow is:
Domain code should be able to run without the app.
That means domain code should not require:
- Flutter
- Riverpod
- Flame
- local file paths
- HTTP clients
- WebSocket connections
- screen navigation
- platform APIs
Application code can coordinate those concerns through interfaces, but the domain should stay portable.
That portability is what lets the same command and rules language support local play, server play, AI, tests, and tooling.
The Shared Domain
There is one extra wrinkle: Age of New Worlds has a shared package, aonw_core.
That package contains domain concepts that must be shared by both the Flutter client and the Dart server:
packages/aonw_core/lib/
game/domain/
command/
event/
fog/
runtime/
state/
unit.dart
city.dart
ruleset.dart
map/domain/
ai/
protocol/
This shared kernel keeps the client and server speaking the same language.
Commands and events are especially important. If the client sends MoveUnitCommand, the server must parse and understand the same command shape. If the server emits UnitMoved, the client must decode and display the same event.
What Does Not Belong in the Domain
Some things are tempting to put in the domain, but I try to keep them out.
Rendering does not belong there. The domain may say that a unit moved, but it should not know how the unit slides across the map.
Persistence does not belong there. The domain may define serializable state, but it should not know whether the state is saved to JSON, PostgreSQL, or returned over HTTP.
Screen workflow does not belong there. The domain may say the active player changed, but it should not know whether Flutter shows a handoff overlay.
Network authority does not belong there. The domain can define legal commands, but the server decides who is allowed to submit them and whether a tick is stale.
The Practical Benefit
The benefit is not theoretical cleanliness. The benefit is that features have somewhere to go.
When I add a new combat rule, it belongs in the domain.
When I add a new way to save or load a match, it belongs behind an application port and infrastructure adapter.
When I add a new HUD panel, it belongs in presentation.
When I add a different end-turn flow for multiplayer, it belongs in an application strategy.
When I add a server projection, it belongs in the server boundary, not in the renderer.
This makes the project easier to extend because I do not have to ask the whole codebase for permission every time a feature grows.
The Trade-Off
This architecture adds files.
There are reducers, use cases, services, ports, adapters, snapshots, commands, events, providers, and render layers. For a tiny prototype, that would be too much.
But Age of New Worlds is a 4X game. The systems overlap constantly.
A single turn can involve movement, production, research, fog of war, combat, objectives, AI, saves, events, UI effects, and multiplayer state. Without boundaries, that becomes one giant knot.
The domain/application split is how I keep the knot from tightening.
The Rule I Keep Coming Back To
The domain should describe the game.
The application layer should describe how the app uses the game.
That distinction sounds small, but it has shaped almost every important part of Age of New Worlds. It lets the renderer stay visual, the server stay authoritative, the save system stay replaceable, and the rules stay testable.
For a strategy game, that is the difference between code that merely works today and code that can survive the next system I add tomorrow.
Leave a Reply