⚓ Seaworth Games

lib/rmg

Random Map Generator

One region of the systems map, expanded: how a template becomes a graph of zones, how those zones get carved and filled by a dependency graph of ~15 pluggable steps, and why the whole thing only ever runs once, on the server, from a single seed.

Where it starts

Entirely server-side, entirely inside game setup — not a separate tool, not something the client ever runs.

When a host picks a random map instead of a .vmap file, CGameState::initNewGame() checks scenarioOps->createRandomMap() and, if true, builds a CMapGenerator seeded from the server's own RNG (randomGenerator.nextInt() — itself fixed if the host set settings.server.seed) and calls generate(). That's it: one seed, one call, one thread of control.

Nothing downstream knows the difference. generate() returns an ordinary CMap — the same type a loaded .vmap file would produce — and from that point on it's just the map. RMG doesn't bypass the systems map's central rule that the server is the sole writer of state; it's simply one of the two ways CGameState gets its opening content, finished before that rule has anything to broadcast yet.

The generation pipeline

CMapGenerator::generate(), in the order it actually runs.

addHeaderInfo() + initTiles()

players, difficulty, and a blank terrain grid the right size

→

genZones()

CZonePlacer::placeZones() — lays the zone graph onto the grid

→

addModificators()

every zone gets its list of fill-steps registered — nothing runs yet

→

fillZones()

the registered modifiers actually execute, as a scheduled job pool

Once the pool drains: the Grail is dropped into a random treasure zone's free tile, the map editor's undo history is discarded, and the finished CMap is handed back — same shape as any other loaded map.

Placing zones

Before anything is filled, the zone graph has to be laid out on the grid — four passes, each one refining the last.

  1. findPathsBetweenZones()Dijkstra over the template's connection graph — who's adjacent to whom, and how far
  2. initial grid seedingzones dropped one by one onto the smallest N×N grid that fits them, close pairs kept close, distant pairs pushed apart
  3. Fruchterman–Reingold relaxationmoveOneZone() — zones as soft spheres: connections pull like springs, overlaps push back; simulated annealing cools them from squishy to rigid over iterations
  4. Penrose tilingan irregular vertex mesh is laid over the map and assigned zone-by-zone — what gives zones their hand-drawn borders instead of straight polygon cells

The modifier graph

Zone filling isn't a fixed sequence of function calls. It's a dependency graph of roughly fifteen step types, scheduled purely by readiness.

Every fill-step derives from Modificator. Its init() declares who it needs to finish first (dependency(), or the DEPENDENCY / DEPENDENCY_ALL macros); its process(), invoked through run(), does the actual work. fillZones() flattens every zone's modifier list into one queue and repeatedly scans it: anything isReady() — no unfinished preceder left — is handed to a tbb::task_group (or run in place, in declared order, when config.singleThread is set for reproducible output); once isFinished(), it drops out and whatever depended on it moves closer to ready. No global order exists beyond what the dependencies encode.

ZONE A (underground) ZONE B (underground) TreasurePlacer zone A TreasurePlacer zone B RockPlacer zone A RockPlacer zone B dependency RockFiller one per underground level DEPENDENCY_ALL(RockPlacer)
RockPlacer::init() depends on TreasurePlacer in every zone sharing its level, so rock never buries a treasure that hasn't been placed yet. RockFiller::init() then declares DEPENDENCY_ALL(RockPlacer) — the one shared filler for a level waits on every underground zone's RockPlacer, wherever the scheduler happened to run them. It's a hand-written topological sort, not a call order anyone could read off top to bottom.

Registration happens once, in RmgMap::addModificators(), and which steps a zone gets depends entirely on what kind of zone it is:

Every zone

baseline fill-steps, no zone is without them

ObjectManagerTreasurePlacerObstaclePlacerTerrainPainter
Once per map

singleton steps, registered on the first zone only

ObjectDistributorPrisonHeroPlacer
Land zones

everything a non-water zone needs to be playable

TownPlacerMinePlacerObjectPlacerQuestArtifactPlacerConnectionsPlacerRoadPlacerRiverPlacer
Water zones

the one water zone, plus every zone once it exists

WaterAdopterWaterProxyWaterRoutes
Underground zones

rock placed after treasure, one filler per level

RockPlacerRockFiller

What actually happens inside these steps is described well enough in prose already — adjacent zones get a guard and a road, overlapping zones on different levels get a Subterranean Gate, distant zones route through the single water zone or seal their coast; every zone fractalizes outward from a center tile into a web of free paths; treasures fill in from highest value to lowest, each pile kept a minimum distance from the last; whatever's left over is blocked and packed with obstacles, biggest pieces first.

Templates

A CRmgTemplate is the graph plus the knobs — nothing about how to fill a zone, only what it should end up being.

Two collections carry almost everything: zones (size, type, terrain, treasure value range, town hints) and connections (the edges both CZonePlacer's Dijkstra pass and the ConnectionsPlacer modifier consume). Zones can reference each other instead of repeating themselves — afterLoad() resolves inheritTerrainType(), inheritMineTypes(), inheritTreasureInfo() and inheritTownProperties() recursively, so a template can say “zone 4: same terrain as zone 1” instead of spelling it out. It's the same by-reference idiom that shows up again inside TownPlacer::init(), which can make one zone's town choice depend on another's — templates and modifiers both lean on the same trick.

The schema lives at config/schemas/template.json; the numbers that tune the algorithm itself — the Fruchterman–Reingold cooling schedule, treasure density curves — sit in config/randomMap.json, not hardcoded into the C++.