The presentation layer has a tempting job.
It shows the map. It reacts to clicks. It animates units. It displays city panels, movement previews, action buttons, fog of war, territory, alerts, and turn information. From the player’s perspective, this layer often feels like “the game”.
But architecturally, it is not the game.
The presentation layer is the place where the game becomes visible and interactive. It is not the place where the rules are decided. That distinction became one of the most important boundaries in the project.
What the Presentation Layer Owns
The presentation layer owns experience.
It decides how information is shown, how the player interacts with it, and how the current state is translated into something readable on screen.
In simplified form, this is the shape of the layer:
lib/game/presentation/
├── screens/
├── hud/
├── renderer/
├── providers/
├── view_models/
└── effects/
The exact structure changes as the project grows, but the responsibility stays the same:
- Flutter builds screens, panels, HUDs, dialogs, and controls.
- Flame renders the map, units, overlays, and animations.
- Riverpod connects state, use cases, and view models.
- View models shape domain state for the UI.
- Effects describe things that should happen visually.
The presentation layer answers questions like:
- Which unit is selected?
- Which action buttons should be visible?
- Which tile is highlighted?
- Should the camera move?
- Should a movement animation play?
- What should the HUD display for the active player?
It does not answer questions like:
- Can this unit legally move there?
- Does this city finish production this turn?
- Is this tile visible through fog of war?
- Has this player already submitted their turn?
- Should this command be accepted by the server?
Those questions belong deeper in the application and domain layers.
Flutter and Flame, Side by Side
One of the architectural decisions I like most in Age of New Worlds is using Flutter and Flame together, but not forcing one to do the other’s job.
Flutter is excellent for interface-heavy screens. Strategy games have a lot of UI: panels, buttons, lists, tooltips, production queues, research choices, editor controls, save screens, and menus.
Flame is better suited for the interactive world: the hex map, camera movement, tile layers, units, visual effects, overlays, and input on the game board.
So the game screen is conceptually a stack:
Stack(
children: [
CappedGameWidget(game: _renderer),
GameHud(...),
],
);
Flame sits underneath as the world renderer. Flutter sits above it as the interface.
That gives me a clean split:
flowchart TB
Player["Player"]
Flutter["Flutter HUD / Panels"]
Flame["Flame Map Renderer"]
Commands["Game Commands"]
State["Game State"]
Effects["Presentation Effects"]
Player --> Flutter
Player --> Flame
Flutter --> Commands
Flame --> Commands
Commands --> State
State --> Flutter
State --> Flame
Effects --> Flame
Effects --> FlutterBoth Flutter and Flame can produce intent. Neither one owns the rules.
A button can request EndTurnCommand.
A tile tap can request SelectTileCommand.
A unit action can request MoveUnitCommand.
But the command still has to go through the application layer and reducer before anything becomes true.
The Renderer Is an Adapter
The Flame renderer is one of the busiest parts of the project, but I try to keep its role narrow.
It knows how to draw a hex grid.
It knows how to position units.
It knows how to display fog.
It knows how to animate movement.
It knows how to move the camera.
It does not know whether a movement is legal.
That is a subtle but important rule.
A renderer method can be allowed to say:
onCommand(MoveUnitCommand(unitId: unit.id, target: hex));
But it should not decide:
unit.position = target;
unit.movementPoints -= cost;
That second version is the beginning of trouble. It makes the visual layer responsible for game truth. It also makes saves, multiplayer, replay, testing, and AI much harder, because the rules are now hidden inside interactive presentation code.
Instead, the renderer emits intent. The domain processes the intent. Then the renderer receives updated state and visual effects.
sequenceDiagram
participant Renderer
participant App as Application Layer
participant Domain as Domain Reducer
participant State as Game State
Renderer->>App: MoveUnitCommand
App->>Domain: reduce(command, currentState)
Domain->>State: new state
Domain-->>App: effects
App-->>Renderer: sync state + play effectsThis keeps Flame powerful, but contained.
View Models: Preparing State for the Screen
The domain state is not always the best shape for rendering.
A domain model should be correct and expressive for rules. A UI model should be convenient for display.
That is why the presentation layer uses view models.
A city in the domain might contain population, stored food, production queue, controlled hexes, worked hexes, buildings, hit points, specialization, and expansion preferences.
The HUD may only need:
- city name,
- current production,
- turns remaining,
- food progress,
- warning badges,
- available actions.
The renderer may need something else:
- city center position,
- owner color,
- territory hexes,
- visibility state,
- marker style.
Instead of making widgets dig through raw domain objects, I prefer shaping state into presentation-specific models.
flowchart LR
Domain["Domain State"]
VM["View Models"]
Flutter["Flutter Widgets"]
Flame["Flame Components"]
Domain --> VM
VM --> Flutter
VM --> FlameThis gives the UI a stable language without pushing display concerns back into the domain.
Riverpod as the Presentation Coordinator
Riverpod is the bridge between the state of the game and the widgets that need to react to it.
It is not the domain. It is not the reducer. It is not the save system.
In this project, Riverpod is mostly coordination:
- expose current game/session state,
- create derived view models,
- connect buttons and gestures to use cases,
- rebuild the right UI when state changes,
- keep screen-level state manageable.
The mistake would be to let providers slowly become the place where rules live.
For example, this is fine:
final selectedUnitViewModelProvider = Provider((ref) {
final state = ref.watch(gameStateProvider);
return SelectedUnitViewModel.fromState(state);
});
But this would be a bad direction:
final selectedUnitProvider = StateProvider((ref) {
// Move unit, subtract movement, reveal fog, update city state...
});
That kind of logic belongs in commands, reducers, and domain services.
Riverpod helps the presentation layer stay reactive. It should not become a second domain model.
Effects: When State Is Not Enough
Not everything visible on screen should be stored as permanent game state.
If a unit moves, the final position belongs in the state.
The animation between the old position and the new position does not.
If combat happens, the result belongs in the state.
The camera shake and particle burst do not.
If a city finishes production, the new unit or building belongs in the state.
The floating text bubble is presentation.
That is why I use effects.
A reducer can return state changes and also describe what the UI should show:
GameStateTransition(
state: nextState,
events: events,
uiEffects: [
AnimateUnitMoveEffect(...),
ShowFloatingTextEffect(...),
],
);
The important part is that effects are not hidden side effects. They are explicit results of processing a command.
That makes them easier to test, easier to replay, and easier to ignore in contexts that do not need visuals.
A server does not care about camera shake.
A test does not need particle effects.
A renderer does.
Synchronizing the World
Once the state changes, the renderer has to catch up.
The presentation layer has a coordinator that syncs the visible world from the current state. In simplified terms, it updates:
- selected tiles,
- planning markers,
- city markers,
- territory,
- units,
- fog of war,
- movement previews,
- action palettes,
- overlays,
- floating effects.
This is the direction I want:
GameState -> RenderViewModel -> Renderer Sync
Not this:
Renderer Components -> Hidden Game State
That direction matters because Flame components are visual objects. They are good at rendering and animation, but they should not become the source of truth for the game.
If a unit component disappears, the unit should not disappear from the game.
If the camera is moved, the player’s empire should not change.
If an overlay fails to render, the simulation should still be valid.
Presentation and Fog of War
Fog of war is a good example of why boundaries matter.
The presentation layer displays fog: hidden tiles, discovered tiles, visible tiles, softened overlays, and shader effects.
But it does not decide what the player can see.
Visibility is computed by domain logic. The renderer receives visibility information and turns it into pixels.
flowchart LR
Units["Units and Cities"]
FogRules["Fog of War Rules"]
Visibility["Visibility State"]
Renderer["Fog Overlay Renderer"]
Units --> FogRules
FogRules --> Visibility
Visibility --> RendererThis becomes especially important for multiplayer. A client should not merely hide enemy information visually. The state itself must already be projected correctly before it reaches the player.
Presentation can obscure.
Projection must protect.
Presentation and the Map Editor
The map editor uses many of the same presentation foundations as the game: a hex world, camera movement, tile interaction, map images, and overlays.
But the editor has a different intent.
In the game, clicking a tile might select a unit, plan movement, or inspect terrain.
In the editor, clicking a tile might paint terrain, change height, add resources, or validate city founding positions.
This is another reason I like keeping the renderer separate from the rules. The same visual foundation can support different interaction models.
The editor can reuse the hex rendering system without pretending to be a running match.
What This Layer Should Not Know
The presentation layer should not know how to process a turn.
Ending a turn is not a UI operation. The button is only the visible trigger.
Behind that button, the application and domain layers may process:
- city growth,
- production,
- research,
- worker jobs,
- city founding,
- queued movement,
- fog recomputation,
- player activation,
- turn advancement.
The presentation layer can show the button.
It can disable it.
It can display warnings.
It can show the result.
But it should not become the turn engine.
This rule applies across the whole game. The presentation layer should express intent and display consequences. It should not quietly invent consequences.
Why This Boundary Matters
Keeping presentation separate from game logic has practical benefits.
It makes the game easier to test because reducers can be tested without rendering anything.
It makes multiplayer more realistic because the same commands can be sent to a server instead of being trapped in widget callbacks.
It makes saves cleaner because the persistent state is not mixed with camera position, hover state, animations, and temporary UI previews.
It makes AI easier because AI can use the same commands as the player without needing Flutter or Flame.
It makes the editor possible because map interaction can be reused without dragging the whole game runtime with it.
Most importantly, it keeps the project understandable.
A 4X game already has enough complexity. The presentation layer should help the player understand that complexity. It should not add hidden complexity of its own.
The Rule I Keep Coming Back To
The presentation layer is where the game becomes visible.
It is where state becomes panels, markers, animations, highlights, previews, and feedback. It is where the player touches the game.
But the presentation layer does not own the game.
In Age of New Worlds, Flutter and Flame are essential. They make the game feel alive. Riverpod keeps the presentation reactive. View models keep screens readable. Effects make visual feedback explicit.
But the truth of the game still lives behind commands, reducers, domain rules, and application use cases.
That boundary is not always easy to maintain, especially when a quick UI shortcut would solve a problem in five minutes. But every time I keep the presentation layer honest, the rest of the project becomes easier to grow.
Leave a Reply