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_REGISTRYdefines district types & tags.-
STREET_REGISTRYis a pool of street names with tags (used for naming runs of edges). -
PLACE_REGISTRYdefines place types, placement constraints, and place props.
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)
- Create locations (district types + tags) and lay them out on a jittered grid.
- Connect the graph using a planar Euclidean MST plus a few extra local edges (no crossings).
-
Name streets by grouping edges into “runs” and assigning
names from
STREET_REGISTRY. -
Populate places into locations using
PLACE_REGISTRY, capacity limits, and density.
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
- Locations are laid out on a grid (columns ≈ √N), then jittered inside each cell.
-
Coordinates are stored on the location as
x/y. - Coordinates are used for connection geometry (distance sorting, crossing tests), not for travel time.
Graph connection (streets)
How edges are created
-
All location pairs are considered with Euclidean distance
hypot(dx, dy). - A minimum spanning tree (Kruskal) is built first (planar in Euclidean space).
- Then, a few extra edges per node are attempted to nearby nodes (k-nearest neighbors).
- Any edge that would cross an existing edge is skipped (unless it shares an endpoint).
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). |
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)
- Runs prefer starting from edges touching low-degree nodes (dead-ends / small junctions).
- Runs extend by following unassigned incident edges, avoiding immediately going back if alternatives exist.
- At intersections (degree ≥ 3), the run may stop once it has at least 2 edges.
- Density increases run length. Higher density increases both the max run length and the chance to continue through intersections.
- If a run ends up as a single edge and can’t be extended, the algorithm tries to “merge” it into a neighboring already-named street.
-
If the registry is exhausted, the engine falls back to
"Road 1","Road 2", …
Places: how they’re generated
Key constraints
-
Each location has a hard capacity:
capacityPerLocation = 10. -
Place types can define
maxCountandminDistanceconstraints. -
Place types can be limited to certain district tags via
allowedTags.
Placement stages
- Stage 0: greedy bus stops (special case; see below)
- Stage 1: satisfy minimum counts and singletons
- Stage 2: density-driven extras (respects soft targets)
- 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:
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). |
minDistance with few candidate districts), the generator may stop
early and place fewer than requested.
How density affects the world
-
Location count: when
locationCountis not explicitly provided, it’s derived from required minimum places. Higherdensityincreases the computed number of locations. -
Extra places: if a place type has a finite
maxCount, density can add “extra” instances up to that cap in Stage 2. - Soft per-location targets: Stage 2 uses a per-location “soft target” (based on node degree) to discourage overstuffing one location with all the extras.
- Street name runs: higher density produces longer continuous street name runs.
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 stops are placed before other place types.
- This greedy pass intentionally bypasses the usual “soft target” and capacity checks used for extras.
- Bus stops still consume a slot in the location’s capacity, so placing “a bus stop everywhere” reduces room for other places.
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
-
props.travelTimeMult— bus ride time multiplier (smaller = faster bus) -
props.busFrequencyDay/props.busFrequencyNight— minutes between buses, used to compute wait time
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. |
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
- If
props.openingHoursis present, it wins. -
Else, if the place key has an override in
DEFAULT_OPENING_HOURS_BY_KEY, it uses that. -
Else, if the first
props.categoryhas a default inDEFAULT_OPENING_HOURS_BY_CATEGORY, it uses that. - Else, the default is 24/7.
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)
-
Edit
src/data/world/location.jsand add an entry toLOCATION_REGISTRY. -
Give it a unique
key, alabel, and a set oftagsthat will matter for placement and naming. -
If you want it to always appear, add
min. If you want to cap it, addmax.
Add new street name variants
-
Edit
src/data/world/street.jsand add an entry toSTREET_REGISTRY. - Give it a unique
keyand aname. -
Optionally add
tagsthat match district tags where that street name “fits”.
Add a new place type
-
Edit
src/data/world/place.jsand add aPLACE_REGISTRYentry with a uniquekey. -
Choose
allowedTagscarefully to ensure there are enough candidate locations. -
Use
minCount/maxCountto control how many appear. UseminDistanceto spread them out. -
Set
props.categoryso NPC target selection and opening-hour defaults work. -
If it has special access rules (age, opening hours), set
props.agesand/orprops.openingHours. - If it’s bus-related, include the bus props described above.
-
Very small candidate set + very high
minCountorminDistance -
maxCountlower thanminCount(the generator clamps, but it won’t do what you expect) -
Leaving
props.categoryempty 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().