World Map Generation & World Data Reference

This page documents how the procedural world map is generated (src/classes/world/util/worldmap.js), and how to add new districts, streets, and places (src/data/world/location.js, src/data/world/street.js, src/data/world/place.js). It describes structures and expectations without listing the actual tags/entries (those live in the data files).

Quick mental model

What the “map” is

  • Locations = nodes in a graph (districts).
  • Streets = edges between locations (travel links).
  • Places = points-of-interest attached to locations (shops, parks, stations, etc.).

Where data comes from

  • LOCATION_REGISTRY defines district types & tags.
  • STREET_REGISTRY is a pool of street names with tags (used for naming runs of edges).
  • PLACE_REGISTRY defines place types, placement constraints, and place props.
IDs in the world graph are stored as strings internally. Even if you pass numbers, they’re normalized via String(id).

WorldMap constructor inputs

new WorldMap({
  rnd,              // required for deterministic generation
  density: 0.0,     // 0..1 (affects location count + extra places + street name runs)
  mapWidth: 100,    // layout coordinate span (x)
  mapHeight: 50,    // layout coordinate span (y)
})
Option Required? Meaning
rnd required Random number generator function returning 0..1. Used everywhere (layout, edges, names, placement).
density optional 0..1. Higher density generally means more locations and more “extras” for places, and longer continuous street name runs. Default: 0.
mapWidth, mapHeight optional Used only for the 2D layout (for spacing and street crossing checks). Travel time is not derived from these values.

Generation pipeline (what happens, in order)

  1. Create locations (district types + tags) and lay them out on a jittered grid.
  2. Connect the graph using a planar Euclidean MST plus a few extra local edges (no crossings).
  3. Name streets by grouping edges into “runs” and assigning names from STREET_REGISTRY.
  4. Populate places into locations using PLACE_REGISTRY, capacity limits, and density.
“No crossings” means edges are skipped if they would geometrically intersect existing edges (except at shared endpoints). That helps keep the map readable and mostly planar.

Locations (districts)

District types are defined in src/data/world/location.js as LOCATION_REGISTRY entries. During generation, the engine picks district definitions to match required minimums first, then fills the rest using weighted random picks while respecting optional max caps.

LOCATION_REGISTRY entry shape

Field Required? Meaning / rules
key required Unique identifier for the district type (string).
label optional Human-readable base name. Used by default naming if present; otherwise falls back to key.
tags optional Array of district tags (strings). Places and street names use these tags for placement/naming bias. (Tags themselves are declared in the same file; this doc does not list them.)
weight optional Relative chance of being chosen when filling non-required slots. Default: 1.
min optional Hard minimum count of this district type in the map. Mins are applied first. If overall location count is too small, mins are satisfied until the map runs out of slots.
max optional Maximum count of this district type. When the generator fills remaining slots, it avoids exceeding max.

How locations are positioned

Naming: the default district name function appends a letter suffix (A, B, C, …) when multiple districts share the same base. So you typically get names like “Downtown A”, “Downtown B”, etc.

Graph connection (streets)

How edges are created

Street (edge) properties

Property Meaning
minutes Travel time for that edge. Set once at generation time and clamped to 1–5 minutes. (This is what pathfinding uses.)
distance Purely descriptive distance value (meters), derived from Euclidean layout distance and floored to ≥ 50. It does not currently drive travel time.
streetName Name assigned after the graph is built (see below).
Pathfinding uses getTravelMinutes() / getTravelTotal(), both based on summed edge.minutes. The returned getTravelTotal() also includes the sequence of location IDs and edge objects.

Street naming

Street naming does not come from the generated geometry directly. Instead, edges are grouped into “runs” and each run gets a name. Names are chosen from STREET_REGISTRY and each registry entry is used at most once per map.

STREET_REGISTRY entry shape

Field Required? Meaning
key required Unique identifier for the name entry. Used to prevent reuse within one generated map.
name required The actual street name string (“Market Street”, etc.).
tags optional Location tags this street “fits”. When a run starts near a location, entries whose tags overlap that location’s tags are weighted higher.

Run formation rules (important if you care about naming feel)

Places: how they’re generated

Key constraints

  • Each location has a hard capacity: capacityPerLocation = 10.
  • Place types can define maxCount and minDistance constraints.
  • Place types can be limited to certain district tags via allowedTags.

Placement stages

  1. Stage 0: greedy bus stops (special case; see below)
  2. Stage 1: satisfy minimum counts and singletons
  3. Stage 2: density-driven extras (respects soft targets)
  4. Stage 3: ensure at least 1 place per location

PLACE_REGISTRY entry shape

Field Required? Meaning / rules
key required Unique place type key (string). The generator and schedulers use this key heavily. Don’t use id instead — map generation reads key.
label optional Human-readable label used as a fallback for naming.
props optional Arbitrary metadata copied into the instantiated Place.props (icons, categories, opening hours, age gates, etc.). Documented below.
nameFn(context) optional Function returning the base name for an instance. Called during placement. Context shape:
({ tags, rnd, index, locationId }) => "Some Name"
where tags are the location’s district tags, rnd is RNG, and index counts prior placements of the same key.
allowedTags optional Array of district tags. If provided, the place can only be placed in locations that contain any of these tags. If omitted, the place may be placed anywhere (subject to other constraints).
minCount optional Minimum number of instances the generator will try to place in Stage 1. Default is effectively 0.
maxCount optional Maximum number of instances. If omitted, effectively unlimited. (But you still hit per-location capacity and distance constraints.)
minDistance optional Minimum distance between two instances of the same key. Important: when no custom distance function is supplied, this is a graph hop count (BFS steps), not meters.
weight optional Stored on the definition but not directly used by the current placement algorithm (placement is driven primarily by min/max counts, density extras, and constraints).
Placement is best-effort: if constraints are too strict (e.g., very high minDistance with few candidate districts), the generator may stop early and place fewer than requested.

How density affects the world

The generator still hard-limits places per location to 10 total instances (including bus stops).

Why bus stops are special

The key "bus_stop" is handled specially in worldmap.js: the generator first performs a greedy placement pass that tries to place bus stops widely across the map, subject only to minDistance between bus stops.

Bus travel in NPC movement

NPC bus travel looks for the nearest place whose place.key is either "bus_stop" or "bus_station". If you add a new bus-station-like place, ensure it uses one of those keys or update the lookup logic.

Bus stop props that matter

If a “bus stop” place lacks those props, bus planning may fail or behave strangely. Keep those fields present on any bus-stop-like place used by the bus system.

Place props

props is a free-form object copied onto the instantiated Place. The engine currently cares about the fields below; additional fields are allowed for UI or gameplay logic.

props field Type Meaning / used by
icon string UI label (emoji, etc.).
category string or array Place categories/tags. Used by target selection (placeCategory) and by opening-hours inference (default schedule is chosen using the first category when no key override exists).
openingHours schedule object Weekly schedule used by Place.isOpen(atTime) and by NPC scheduling when rules have respectOpeningHours. If omitted, opening hours are inferred (by key first, then by category, otherwise 24/7).
ages {min?: number, max?: number} Age gate used by the NPC scheduler when respectAgeRestriction is true/absent on a rule. If the NPC’s age is below min or above max, the place is excluded unless the rule opts out.
travelTimeMult number Bus-only: multiplier applied to bus ride time.
busFrequencyDay, busFrequencyNight number (minutes) Bus-only: used to compute waiting time aligned to the clock.
Place instances clone openingHours so each place has its own schedule object (no shared mutation).

Opening hours: format & inference

Schedule format

openingHours is a map of day keys to arrays of slots. Day keys are 3-letter lowercase strings (mon/tue/wed/thu/fri/sat/sun). Slots can be objects or two-element arrays.

props: {
  openingHours: {
    mon: [{ from: "09:00", to: "17:00" }],
    tue: [["09:00", "17:00"]],
    wed: [],
    thu: [{ from: "09:00", to: "17:00" }],
    fri: [{ from: "09:00", to: "17:00" }],
    sat: [{ from: "10:00", to: "14:00" }],
    sun: []
  }
}

Crossing midnight

A slot where to is earlier than from is treated as “overnight”: e.g. 18:00 → 03:00 means open 18:00–24:00 on that day, and 00:00–03:00 on the next day. The Place.isOpen() implementation checks both the same day and the “after midnight” remainder of the previous day.

Default opening hours

Opening-hour checks use UTC day/hour/minute (from Date.getUTCDay(), etc.). If your game presents local time to players, keep that mapping in mind when authoring schedules.

How to add new content

Add a new district type (location)

  1. Edit src/data/world/location.js and add an entry to LOCATION_REGISTRY.
  2. Give it a unique key, a label, and a set of tags that will matter for placement and naming.
  3. If you want it to always appear, add min. If you want to cap it, add max.

Add new street name variants

  1. Edit src/data/world/street.js and add an entry to STREET_REGISTRY.
  2. Give it a unique key and a name.
  3. Optionally add tags that match district tags where that street name “fits”.
Street registry entries are a name pool. They don’t create streets — they only influence naming of generated edges.

Add a new place type

  1. Edit src/data/world/place.js and add a PLACE_REGISTRY entry with a unique key.
  2. Choose allowedTags carefully to ensure there are enough candidate locations.
  3. Use minCount / maxCount to control how many appear. Use minDistance to spread them out.
  4. Set props.category so NPC target selection and opening-hour defaults work.
  5. If it has special access rules (age, opening hours), set props.ages and/or props.openingHours.
  6. If it’s bus-related, include the bus props described above.
Avoid impossible combinations:
  • Very small candidate set + very high minCount or minDistance
  • maxCount lower than minCount (the generator clamps, but it won’t do what you expect)
  • Leaving props.category empty when you want NPCs to find the place by category

Name functions: what they receive

Place nameFn

Called during instantiation in the generator. Signature is effectively:

nameFn({
  tags,        // the location's district tags (array)
  rnd,         // RNG function (0..1)
  index,       // 0-based count of already-placed instances of this place key
  locationId,  // the location ID where this instance is being created
}) => string

The generator enforces uniqueness per place key: if the name repeats, it auto-suffixes “ 2”, “ 3”, … up to 99. If it can’t find a unique name, that placement attempt is abandoned.

District naming

District naming is currently handled inside worldmap.js by a default naming function. If you want custom district naming, you’d change the call site in createLocations().