In Age of New Worlds, the map is not just a background.
It is the board, the terrain model, the input surface, the rendering foundation, the source of movement rules, the basis for city placement, the thing fog of war hides and reveals, and the content artifact that I need to edit, validate, save, and ship.
That means the map cannot be only an image.
It has to be data.
The Map Starts as Data
At the lowest level, the map is a grid of hex tiles.
Each tile has:
- a column and row
- one or more terrain types
- zero or more resources
- a height value
The simplified model looks like this:
class TileData {
final int col;
final int row;
final List<TerrainType> terrains;
final List<ResourceType> resources;
final int height;
}
The whole map is a collection of those tiles:
class MapData {
int cols;
int rows;
final List<TileData> tiles;
String? mapName;
double defaultZoom;
TileData? tileAt(int col, int row) {
for (final tile in tiles) {
if (tile.col == col && tile.row == row) return tile;
}
return null;
}
}
One detail here is intentional: MapData is mutable because the editor needs to modify tiles in place. The game treats loaded maps as content, but the tooling needs to reshape them.
A Simplified Map Pipeline
flowchart TD Json["map.json"] Loader["MapLoader"] Data["MapData"] Geometry["HexGeometry"] Grid["HexGrid"] Tile["HexTile Components"] Layers["Game Rendering Layers"] Game["Playable Map"] Json --> Loader Loader --> Data Data --> Geometry Data --> Grid Grid --> Tile Tile --> Layers Layers --> Game
The map begins as JSON. It becomes MapData. Geometry turns coordinates into world positions. HexGrid turns tiles into Flame components. Then game-specific rendering layers add units, cities, fog, movement previews, territory, and other overlays.
Map Files
A bundled map lives under an asset folder like this:
assets/maps/
verdantia/
map.json
1x1.png
1x2.png
...
The JSON contains the structural map data. Optional images can provide a graphic layer.
The loader is strict about required fields:
if (!map.containsKey('cols')) {
throw const MapLoadException('Missing field: cols');
}
if (!map.containsKey('rows')) {
throw const MapLoadException('Missing field: rows');
}
if (!map.containsKey('tiles')) {
throw const MapLoadException('Missing field: tiles');
}
It also validates bounds and height:
if (tile.height < 0 || tile.height > 5) {
throw MapLoadException(
'Tile height ${tile.height} out of range [0, 5]',
);
}
That matters because bad map data can break almost every system in the game. A missing tile is not only a rendering problem. It can affect movement, fog, city founding, AI, and validation.
Terrain and Resources
Terrain and resources are enums.
enum TerrainType {
ocean,
coast,
lake,
plains,
grassland,
desert,
tundra,
snow,
mountain,
hills,
wetlands,
jungle,
forest,
river;
}
Resources are grouped by role: bonus, luxury, and strategic.
enum ResourceType {
wheat,
fish,
deer,
sheep,
gold,
silver,
gems,
silk,
iron,
coal,
oil,
uranium,
horses;
}
I like having these as domain values instead of loose strings inside gameplay systems. The loader converts strings from JSON into typed values, and invalid names fail early.
Hex Geometry
The map uses flat-top hexagons.
The geometry helper owns the conversion between grid coordinates and world coordinates:
static Vector2 tilePosition({
required int col,
required int row,
required double hexRadius,
}) {
final x = hexRadius + col * 1.5 * hexRadius;
final y =
(sqrt(3) / 2 * hexRadius) +
row * sqrt(3) * hexRadius +
(col.isOdd ? sqrt(3) / 2 * hexRadius : 0);
return Vector2(x, y);
}
This is an odd-q flat-top layout. Odd columns are vertically offset.
That choice affects everything:
- neighbor lookup
- rendering position
- hit testing
- movement
- fog reveal
- city radius
- distance calculations
- map editor interaction
The inverse operation is just as important. When the player taps the map, the renderer needs to know which tile was hit:
static ({int col, int row})? tileAt({
required Vector2 point,
required double hexRadius,
required int cols,
required int rows,
}) {
// estimate nearby columns and rows,
// then test candidate hex polygons
}
That function is the bridge from pointer input to game intent.
Rendering the Grid
HexGrid turns MapData into HexTile components.
class HexGrid extends PositionComponent {
final MapData mapData;
final MapConfig config;
MapViewMode _viewMode;
HexDisplaySettings _displaySettings;
static const double perspectiveY = 0.62;
}
The grid applies a Y scale:
super(scale: Vector2(1.0, perspectiveY));
That gives the map a slightly isometric feel while keeping the underlying logic as a normal hex grid.
Each tile is positioned through HexGeometry.tilePosition(...), then sorted and added to the grid. The tile component handles visual details like terrain color, icons, resource icons, height badge, selection, and markers.
Height and Terracing
Height is one of the most important visual fields in the map data.
A tile does not only draw its top face. It can also draw walls where it is higher than neighboring tiles.
class HexTile extends PositionComponent {
final int tileHeight;
final List<int?> neighborHeights;
}
The comments in the component describe the intent well:
/// Walls are drawn only on sides where this tile is higher than its neighbor.
/// Wall height scales with the height difference, creating a terracing effect.
This means the map can feel more physical without changing the core coordinate system.
The game still reasons in hexes. The renderer makes those hexes feel like terrain.
Graphic Map Layers
The game can render a structural tile map, but it can also place a graphic image layer under the grid.
MapImageLayer supports two modes:
single-image mode:
one image stretches across the full grid
sliced mode:
one image per tile, named like 1x1.png, 1x2.png, ...
The sliced mode is useful when the map art is exported per hex. The layer clips each image to the matching hex shape:
canvas
..save()
..clipPath(slice.clipPath)
..drawImageRect(slice.image, slice.src, slice.dst, _imagePaint)
..restore();
This gives me a flexible path between data-driven rendering and authored map art.
The important part is that the image is not the map truth. It is a visual layer aligned to the map data.
Map Scene Construction
The game scene builder wires the map together.
class GameSceneBuilder {
HexGrid? _grid;
MapImageLayer? _imageLayer;
Future<void> build({
required Component parent,
required MapData mapData,
String? imagePath,
required MapViewMode viewMode,
required HexDisplaySettings displaySettings,
required GameTileTapCallback onTileTapped,
}) async {
_imageLayer = MapImageLayer(
config: MapConfig.defaultConfig,
cols: mapData.cols,
rows: mapData.rows,
);
await parent.add(_imageLayer!);
_grid = HexGrid(
mapData: mapData,
config: MapConfig.defaultConfig,
viewMode: viewMode,
displaySettings: displaySettings,
onTileTapped: onTileTapped,
autoSelectOnTap: false,
);
await parent.add(_grid!);
}
}
The order matters: image first, grid above it, game layers above the grid.
flowchart TD Image["MapImageLayer"] Grid["HexGrid / HexTile"] Fog["Fog of War"] Territory["City Territory"] Improvements["Field Improvements"] Units["Units"] UIHints["Movement, Selection, Action Markers"] Image --> Grid Grid --> Territory Territory --> Improvements Improvements --> Units Units --> Fog Fog --> UIHints
In practice, priorities decide exact draw order, but conceptually this is the stack: base map, structural hexes, gameplay overlays, units, fog, and interaction feedback.
Map Validation
A 4X map is not valid just because it loads.
It also has to play well.
The validator checks things like:
- player count
- total tiles
- passable tile ratio
- food resources
- luxury resources
- strategic resources
- starting positions
- first-ring passable tiles
- first-ring food
- distance between starts
- short-game pacing constraints
class MapValidationResult {
final String mapName;
final int playerCount;
final int totalTiles;
final int passableTiles;
final MapResourceSummary resources;
final List<MapStartSiteReport> startSites;
final List<MapValidationIssue> issues;
}
This is where map building becomes game design.
A map can be beautiful and still be bad for the game. If one player starts with no food, or there are too few passable tiles, or strategic resources are missing, the match may be technically playable but strategically broken.
Validation gives me a way to catch those problems before they become mysterious balance issues.
The Editor Loop
The map editor exists because content iteration needs a fast loop.
The same map model supports both the game and the editor:
flowchart LR Editor["Map Editor"] MapData["MapData"] Json["map.json"] Game["Game Runtime"] Editor --> MapData MapData --> Json Json --> Game
This is why MapData can be serialized back to JSON:
static String toJson(MapData mapData) {
final map = <String, dynamic>{
'cols': mapData.cols,
'rows': mapData.rows,
if (mapData.mapName != null) 'mapName': mapData.mapName,
if (mapData.defaultZoom != 1.0) 'defaultZoom': mapData.defaultZoom,
'tiles': mapData.tiles.map((t) => t.toJson()).toList(),
};
return const JsonEncoder.withIndent(' ').convert(map);
}
The editor is not separate from the architecture. It uses the same data shape the game uses. That keeps content creation honest.
The Boundary I Care About
The map knows terrain, resources, height, coordinates, and dimensions.
It does not know who owns a city. It does not know which unit is selected. It does not know which player is active. It does not know how combat works.
Those belong to game state and domain rules.
But game systems can ask the map questions:
- What tile is at this coordinate?
- Is this tile passable?
- What terrain is here?
- How high is this tile?
- Which hexes are neighbors?
- Is this city site valid?
- Can fog propagate through this terrain?
That boundary keeps the map reusable.
The renderer can draw it. The editor can modify it. The AI can analyze it. The domain can use it for rules.
The Rule I Use
The map is content, geometry, and terrain truth.
It is not the whole game.
That distinction matters because it lets Age of New Worlds build many systems on top of the same foundation: movement, fog of war, city placement, AI planning, validation, rendering, and editor tooling.
A good 4X map is not only something the player looks at. It is something the whole game reasons about.
Leave a Reply