Tiny World Ghost World Generation
makeGhostWorld(boardX, boardZ) produces the contents of a single
non-editable ghost board. It must be:
- Deterministic. Same
(boardX, boardZ) always yields the same
cells. Cached in ghostBoardCells keyed by 'bx,bz'. Panning away
and back must regenerate identically (we rely on this for the
sticky-reveal jigsaw — if content shifted between regens the
reveal cache would lie).
- Connection-aware. Paths and rivers must line up across board
edges. The user should be able to walk a road from one ghost board
into the next.
The seeded RNG
ghostHash(a, b, salt) is a tiny mulberry-style 32-bit mix used for
all board-level decisions. Per-cell randomness goes through the older
cellRand(x, z, salt) but always with global coords
(boardX * GRID + x, boardZ * GRID + z, salt), never local coords.
That guarantees a given world cell renders identically regardless of
which board it was sourced from.
Connection rubric
- Horizontal path Z is a function of
boardZ only
(pathZForRow(boardZ)). Every board on that world row either has
the path at the same Z or has no path on that row.
- Vertical path X is a function of
boardX only
(pathXForCol(boardX)). Every board in the column shares the same
vertical-path X.
- Where horizontal and vertical paths coincide inside a board you
get a crossroads "for free".
- Rivers are column-shared via
riverXForCol(boardX) so they
flow continuously down a column. A river that would collide with
a vertical path is nudged one column over.
- Bridges: where a river crosses a horizontal path, drop a
kind: 'bridge' tile so the path stays walkable.
Rough density knobs (tweak in the helpers themselves):
- ~30 % of world rows have no horizontal path
(
(h % 100) < 30 → -1).
- ~35 % of world cols have no vertical path.
- ~88 % of world cols have no river (so ~12 % do).
Cross-board neighbours
The visual tile renderer needs to know what's on the other side of a
board edge — otherwise a path that exits east terminates with a stub
end-piece. The neighbour helpers handle this:
ghostCellAt(boardX, boardZ, x, z) resolves any local coord. If
x / z are out of [0, GRID) it walks into the adjacent board and
pulls from its makeGhostWorld(...) result. If the wrap lands on
board (0, 0) it reads from the home world[][] instead so user
edits on the home board's edges feed the ghost adjacency too.
getGhostNeighbors(cells, x, z, prop, value, boardX, boardZ) and
getGhostTerrainNeighbors(cells, x, z, boardX, boardZ) use
ghostCellAt so an edge tile sees the real neighbour, not null.
Always pass boardX, boardZ when calling these from inside
buildGhostBoard.
Cells layout
The cell schema must match the home board so setCell /
renderCellObject work on ghost cells too. Always include the full
shape:
{ terrain, kind, floors, buildingType, fenceSide, extras }
Omitting fields (especially extras: []) caused subtle bugs in the
old generator when ghost data flowed through helpers that assumed the
full shape.
Blank ghost boards
The Generate dialog can disable outside auto-fill. That path sets
ghostBoardsBlank = true, clears existing ghost boards, and lets
makeGhostWorld(...) return deterministic blank grass cells for every
off-home board. Keep this as an early return inside makeGhostWorld so
panning remains cheap and no generated scenery appears outside the
current generated board.
Don't
- Don't make paths or rivers depend on both
boardX and boardZ —
that breaks edge continuity.
- Don't seed decoration with local coords. A tree at local (3,4) of
board (1,2) must be identical to that same world cell reached from
any other angle.
- Don't mutate
ghostBoardCells from anywhere except
makeGhostWorld. The reveal system relies on stable references.
User overrides (exceptions)
Anything the user builds / erases on a ghost board is an override and
must survive map regeneration:
- The override lives in
world[gx][gz] at global coords. There is
no separate override map — world[][] is the single source of truth
for user-built cells whether they sit on the home board or far out
in ghost territory.
applyToolToCell copies the generated ghost cell into
world[gx][gz], calls removeGhostCellMesh(boardX, boardZ, lx, lz)
to strip the ghost board's mesh for that cell, then runs applyTool
which calls setCell to render the home cellMesh at the global
coord. The home cellMesh and the ghost board never both render at
the same world position.
ghostCellAt(boardX, boardZ, x, z) prefers world[gx][gz] over
makeGhostWorld(boardX, boardZ)[x][z]. That keeps cross-board
adjacency (paths joining, rivers continuing) correct even when the
user has edited the joining tile.
buildGhostBoard skips any local cell whose global coord exists in
world[][] — those are owned by setCell / cellMeshes.
Persistence
saveState walks Object.keys(world) so every populated cell — home
and far-flung overrides — is serialised, regardless of how far the
user has panned.
applyState restores both home cells (via the staggered drop-in
loop) and out-of-home overrides (via a second setCell pass with
animate: false, forceTile: true). The ghost boards regenerate
deterministically around them and ghostCellAt + buildGhostBoard
ensure overrides paint on top.
The contract: if a user can place / erase it, the world reloads with
that exact change re-applied, anywhere on the map, and the rest of the
ghost world regenerates around it.
Validation
- Pan east across several boards — horizontal paths should run as a
continuous strip; vertical paths and rivers should cross perfectly
perpendicular.
- Pan a known board out of the preload radius then back — the same
trees / houses / crops / rivers reappear in the same cells.
- A river crossing a horizontal path renders a bridge, not water.
- The home board (0, 0) is not affected — its content is the user's,
not the generator's, and paths that line up with the generated row
/col are coincidental.
1---2name: tinyworld-ghost-world-gen3description: Use when changing ghost board generation, path / road / river continuity, deterministic regen, edge connections, or anything that runs inside makeGhostWorld in tiny-world-builder.html.4---56# Tiny World Ghost World Generation78`makeGhostWorld(boardX, boardZ)` produces the contents of a single9non-editable ghost board. It must be:1011- **Deterministic.** Same `(boardX, boardZ)` always yields the same12 cells. Cached in `ghostBoardCells` keyed by `'bx,bz'`. Panning away13 and back must regenerate identically (we rely on this for the14 sticky-reveal jigsaw — if content shifted between regens the15 reveal cache would lie).16- **Connection-aware.** Paths and rivers must line up across board17 edges. The user should be able to walk a road from one ghost board18 into the next.1920## The seeded RNG2122`ghostHash(a, b, salt)` is a tiny mulberry-style 32-bit mix used for23all board-level decisions. Per-cell randomness goes through the older24`cellRand(x, z, salt)` but always with **global** coords25`(boardX * GRID + x, boardZ * GRID + z, salt)`, never local coords.26That guarantees a given world cell renders identically regardless of27which board it was sourced from.2829## Connection rubric3031- **Horizontal path Z is a function of `boardZ` only**32 (`pathZForRow(boardZ)`). Every board on that world row either has33 the path at the same Z or has no path on that row.34- **Vertical path X is a function of `boardX` only**35 (`pathXForCol(boardX)`). Every board in the column shares the same36 vertical-path X.37- Where horizontal and vertical paths coincide inside a board you38 get a crossroads "for free".39- **Rivers** are column-shared via `riverXForCol(boardX)` so they40 flow continuously down a column. A river that would collide with41 a vertical path is nudged one column over.42- **Bridges**: where a river crosses a horizontal path, drop a43 `kind: 'bridge'` tile so the path stays walkable.4445Rough density knobs (tweak in the helpers themselves):4647- ~30 % of world rows have no horizontal path48 (`(h % 100) < 30 → -1`).49- ~35 % of world cols have no vertical path.50- ~88 % of world cols have no river (so ~12 % do).5152## Cross-board neighbours5354The visual tile renderer needs to know what's on the *other side* of a55board edge — otherwise a path that exits east terminates with a stub56end-piece. The neighbour helpers handle this:5758- `ghostCellAt(boardX, boardZ, x, z)` resolves any local coord. If59 `x` / `z` are out of `[0, GRID)` it walks into the adjacent board and60 pulls from its `makeGhostWorld(...)` result. If the wrap lands on61 board `(0, 0)` it reads from the home `world[][]` instead so user62 edits on the home board's edges feed the ghost adjacency too.63- `getGhostNeighbors(cells, x, z, prop, value, boardX, boardZ)` and64 `getGhostTerrainNeighbors(cells, x, z, boardX, boardZ)` use65 `ghostCellAt` so an edge tile sees the real neighbour, not `null`.6667Always pass `boardX, boardZ` when calling these from inside68`buildGhostBoard`.6970## Cells layout7172The cell schema must match the home board so `setCell` /73`renderCellObject` work on ghost cells too. Always include the full74shape:7576```77{ terrain, kind, floors, buildingType, fenceSide, extras }78```7980Omitting fields (especially `extras: []`) caused subtle bugs in the81old generator when ghost data flowed through helpers that assumed the82full shape.8384## Blank ghost boards8586The Generate dialog can disable outside auto-fill. That path sets87`ghostBoardsBlank = true`, clears existing ghost boards, and lets88`makeGhostWorld(...)` return deterministic blank grass cells for every89off-home board. Keep this as an early return inside `makeGhostWorld` so90panning remains cheap and no generated scenery appears outside the91current generated board.9293## Don't9495- Don't make paths or rivers depend on both `boardX` and `boardZ` —96 that breaks edge continuity.97- Don't seed decoration with local coords. A tree at local (3,4) of98 board (1,2) must be identical to that same world cell reached from99 any other angle.100- Don't mutate `ghostBoardCells` from anywhere except101 `makeGhostWorld`. The reveal system relies on stable references.102103## User overrides (exceptions)104105Anything the user builds / erases on a ghost board is an *override* and106must survive map regeneration:107108- The override lives in `world[gx][gz]` at **global** coords. There is109 no separate override map — `world[][]` is the single source of truth110 for user-built cells whether they sit on the home board or far out111 in ghost territory.112- `applyToolToCell` copies the generated ghost cell into113 `world[gx][gz]`, calls `removeGhostCellMesh(boardX, boardZ, lx, lz)`114 to strip the ghost board's mesh for that cell, then runs `applyTool`115 which calls `setCell` to render the home cellMesh at the global116 coord. The home cellMesh and the ghost board never both render at117 the same world position.118- `ghostCellAt(boardX, boardZ, x, z)` prefers `world[gx][gz]` over119 `makeGhostWorld(boardX, boardZ)[x][z]`. That keeps cross-board120 adjacency (paths joining, rivers continuing) correct even when the121 user has edited the joining tile.122- `buildGhostBoard` skips any local cell whose global coord exists in123 `world[][]` — those are owned by `setCell` / `cellMeshes`.124125## Persistence126127- `saveState` walks `Object.keys(world)` so every populated cell — home128 *and* far-flung overrides — is serialised, regardless of how far the129 user has panned.130- `applyState` restores both home cells (via the staggered drop-in131 loop) and out-of-home overrides (via a second `setCell` pass with132 `animate: false, forceTile: true`). The ghost boards regenerate133 deterministically around them and `ghostCellAt` + `buildGhostBoard`134 ensure overrides paint on top.135136The contract: if a user can place / erase it, the world reloads with137that exact change re-applied, anywhere on the map, and the rest of the138ghost world regenerates around it.139140## Validation141142- Pan east across several boards — horizontal paths should run as a143 continuous strip; vertical paths and rivers should cross perfectly144 perpendicular.145- Pan a known board out of the preload radius then back — the same146 trees / houses / crops / rivers reappear in the same cells.147- A river crossing a horizontal path renders a bridge, not water.148- The home board (0, 0) is not affected — its content is the user's,149 not the generator's, and paths that line up with the generated row150 /col are coincidental.