Fog of war looks like a visual feature. The map gets darker. Unknown territory disappears. Previously explored land stays dim. Enemy units appear and vanish. It feels like something that belongs mostly to rendering.
But in AoNW, fog of war is not just an overlay. It is one of the systems that forced me to separate three different ideas:
- what exists in the game world
- what a player has discovered
- what a player can currently see
That distinction matters everywhere. It affects rendering, selection, movement, combat previews, AI decisions, event visibility, and eventually multiplayer.
Three Visibility States
The core visibility model is intentionally small:
enum FogVisibility { hidden, discovered, visible }
extension FogVisibilityX on FogVisibility {
bool get isKnown => this != FogVisibility.hidden;
bool get isVisible => this == FogVisibility.visible;
}
A hidden tile is unknown. The player should not be able to inspect it.
A discovered tile is remembered. The player has seen it before, so static information like terrain can remain available, but dynamic information should not be trusted.
A visible tile is currently in sight. The player can see units, cities, threats, and other live state there.
This is the first important split: remembered terrain is not the same thing as current visibility.
That gives the game a more useful vocabulary than a single boolean like isRevealed.
Per-Player Fog
Fog of war is stored per player.
class FogOfWarState {
final Map<String, PlayerFogOfWar> players;
const FogOfWarState({this.players = const {}});
PlayerFogOfWar fogForPlayer(String playerId) {
return players[playerId] ?? PlayerFogOfWar(playerId: playerId);
}
FogVisibility visibilityFor(String playerId, HexCoordinate hex) {
return fogForPlayer(playerId).visibilityFor(hex);
}
}
Each player has their own discovered and visible sets:
class PlayerFogOfWar {
final String playerId;
final Set<HexCoordinate> discoveredHexes;
final Set<HexCoordinate> visibleHexes;
PlayerFogOfWar({
required this.playerId,
Set<HexCoordinate> discoveredHexes = const {},
Set<HexCoordinate> visibleHexes = const {},
}) : visibleHexes = Set.unmodifiable(visibleHexes),
discoveredHexes = Set.unmodifiable({
...discoveredHexes,
...visibleHexes,
});
}
The constructor makes one invariant explicit: if a tile is currently visible, it is also discovered.
That sounds obvious, but I like having it encoded in the model. Fog of war is a persistence feature as much as a rendering feature. Saves, snapshots, tests, AI, and server projections all depend on the same interpretation of visibility.
Querying Visibility
Most systems should not inspect fog sets directly. They ask a query object.
class FogVisibilityQuery {
final String playerId;
final FogOfWarState state;
const FogVisibilityQuery({
required this.playerId,
required this.state,
});
bool get isEnabled => playerId.isNotEmpty;
bool canInspectTile(TileData tile) =>
visibilityForTile(tile).isKnown;
bool canRememberStaticAt(int col, int row) {
return visibilityForHex(HexCoordinate(col: col, row: row)).isKnown;
}
bool canSeeDynamicAt(int col, int row) {
return visibilityForHex(HexCoordinate(col: col, row: row)).isVisible;
}
}
This is one of the most important small abstractions in the system.
A tile can be known without being dynamically visible. That means the UI may allow terrain inspection while still hiding enemy units. Combat previews can refuse to show a defender that is no longer in sight. AI can reason about visible threats without pretending it has perfect information.
The names matter:
canInspectTileasks whether the player knows the tile exists.canRememberStaticAtasks whether remembered map information is available.canSeeDynamicAtasks whether live entities should be visible.
That gives the rest of the game a way to be precise.
Recomputing Fog
Fog is recomputed from reveal sources: units, city centers, and controlled city hexes.
class FogOfWarService {
FogOfWarState recompute({
required FogOfWarState current,
required MapData mapData,
required Iterable<String> playerIds,
required Iterable<GameUnit> units,
required Iterable<GameCity> cities,
}) {
final updated = <PlayerFogOfWar>[];
for (final playerId in playerIds.where((id) => id.isNotEmpty)) {
final sources = _sourcesForPlayer(
playerId: playerId,
mapData: mapData,
units: units,
cities: cities,
);
final visibleHexes = revealCalculator.visibleHexesFor(
mapData: mapData,
sources: sources,
);
updated.add(
current.fogForPlayer(playerId).withVisibleHexes(visibleHexes),
);
}
return current.updatePlayers(updated);
}
}
I like this shape because it avoids incremental fog bugs.
The game can ask: given the current units, cities, terrain, and ownership, what can each player see right now?
Then the player fog keeps both the newly visible set and the historical discovered set.
PlayerFogOfWar withVisibleHexes(Set<HexCoordinate> visibleHexes) {
return PlayerFogOfWar(
playerId: playerId,
discoveredHexes: {...discoveredHexes, ...visibleHexes},
visibleHexes: visibleHexes,
);
}
That is the whole memory model: current visibility changes every recompute, discovered territory only grows.
Terrain Should Matter
A 4X map is more interesting when vision is not just a circle.
Age of New Worlds uses a reveal calculator that walks outward from each source and applies sight costs, terrain blocking, and elevation rules.
final sightCost = FogVisibilityRules.sightCost(
TileTerrainProfileRules.fromTile(tile),
);
final nextCost = current.cost + sightCost.value;
if (!alwaysVisible && nextCost > source.range) continue;
visible.add(neighbor);
Direct neighbors are always visible. Beyond that, terrain cost matters.
The calculator also stops propagation through blocking terrain and high elevation:
if (sightCost.blocksPropagation) continue;
if (tile.height >
source.observerHeight + FogBalance.elevationBlockingThreshold) {
continue;
}
This makes vision feel more physical. A unit on higher ground can see farther. A ridge can be visible while still blocking what lies behind it. Mountains can shape exploration, not just movement.
This is one of those systems where a small amount of domain logic creates a lot of player-facing texture.
Rendering the Fog
Once the domain knows visibility, the renderer still has to make it feel good.
The Flame side has a dedicated fog layer:
class FogOfWarOverlayLayer {
FogOfWarOverlay? _component;
void sync({
required Component parent,
required MapData mapData,
required FogVisibilityQuery visibility,
}) {
if (!visibility.isEnabled) {
clear();
return;
}
final visibilityByHex = {
for (final tile in mapData.tiles)
HexCoordinate.fromTile(tile): visibility.visibilityForTile(tile),
};
final existing = _component;
if (existing != null) {
existing.updateVisibility(visibilityByHex);
return;
}
final component = FogOfWarOverlay(
mapData: mapData,
visibilityByHex: visibilityByHex,
);
_component = component;
parent.add(component);
}
}
The layer receives a map of hex visibility and turns it into visuals. That keeps the renderer honest: it is not deciding what the player knows, only how to draw that knowledge.
The overlay can use a fragment shader when available, with a fallback path that draws blurred hidden and discovered tiles.
void render(Canvas canvas) {
super.render(canvas);
if (_renderShaderFog(canvas)) return;
_renderFallbackFog(canvas);
}
I like having the fallback because fog should not become a platform gamble. The ideal path can be prettier, but the game still needs to render correctly if the shader cannot load.
Fog Is Also a UX Problem
The hardest part of fog of war is not drawing dark tiles. It is deciding what the player is allowed to do.
If a tile is hidden, should a tap select it? Usually no.
If a tile is discovered but not visible, should the player inspect it? For terrain, yes. For live enemy units, no.
If an enemy unit was visible last turn but moved away, should the combat preview still show it? No, not as live information.
The game uses visibility checks in interaction and HUD logic. For example, a tile cannot be inspected if the active player has never seen it:
if (!state.activePlayerVisibility.canInspectTile(tileData)) {
return GameStateTransition(state: state);
}
Combat UI also has to respect dynamic visibility. A unit that is not currently visible should not appear as an actionable target just because the tile is known.
This is where fog becomes more than rendering. It becomes part of the contract between the game and the player.
AI and Partial Information
Fog also matters for AI.
An AI should not automatically know every enemy unit on the map unless I deliberately run it in a mode that ignores fog for simulation or debugging. The shared AI view uses visible enemy units and visibility queries, which lets planning work with partial information.
That is important because otherwise the AI silently cheats.
Sometimes cheating AI is acceptable in strategy games, but I want that to be an explicit design decision, not an accident caused by architecture.
The same visibility vocabulary helps here too: known terrain, visible enemies, remembered map, hidden threats.
Multiplayer Pressure
Fog of war becomes even more important when thinking about multiplayer.
In local hotseat, it is already useful to prevent the active player from seeing what they should not see. But in server-backed multiplayer, fog is also a data boundary. The server may know the full truth, but each client should receive only the information that player is allowed to know.
That is why I do not want fog to live only inside the renderer. A dark overlay hides information visually, but it does not protect the model.
The real rule is: the player-specific view must be shaped before it becomes UI.
That has consequences for event logs, snapshots, server projections, and network payloads. Fog of war is part of the game’s information architecture.
Why This System Is Worth the Complexity
A simple fog overlay would have been faster.
I could have stored a set of revealed tiles, drawn black polygons over everything else, and moved on. For a prototype, that might have worked.
But Age of New Worlds already has systems that depend on the difference between memory and sight:
- exploration objectives
- enemy visibility
- combat targeting
- city founding decisions
- AI threat assessment
- movement previews
- map inspection
- future multiplayer projections
Once those systems exist, fog of war cannot be a visual afterthought.
It has to be a domain model.
The Rule I Use
The rule I keep coming back to is this:
The game world may contain the truth, but each player only gets a view of that truth.
Fog of war is the system that maintains that view.
Rendering makes it visible. Interaction makes it respectful. AI makes it meaningful. Multiplayer will make it necessary.
That is why fog of war became one of the architectural foundations of Age of New Worlds, not just a nice dark layer over the map.
Leave a Reply