Skip to content

Procedural Generation — Storey Layout Reference

Document Type: Reference Last Updated: 2026-03-21 Vault destination: 05_Reference

Auto Warz — Outpost Mode Last updated: 2026-03-16


What This Document Is

A living reference for the storey layout generator. Written after the first working implementation so the reasoning behind every parameter is captured while it's fresh. Update this whenever a parameter changes or a new system is added.


Overview — How a Storey Gets Made

The generator runs five passes in sequence. Each pass takes the current state of the layout and adds to it. No pass looks backward — each one builds on what came before.

Pass 1  — Dimensions      Set grid width and height from depth + level + StoreyType
Pass 2a — BSP Rooms       Carve rooms and OUT_OF_BOUNDS chunks
Pass 2b — Entry/Trigger   Place ENTRY zone and TRIGGER before corridors
Pass 2c — Corridors       Carve corridors toward TRIGGER (known position)
Pass 3  — Shaft/Stair     Place SHAFT cells and STAIRCASE
Pass 4  — Validation      A* confirms enemy path exists (read-only)
Pass 5  — Annotation      BFS open zone count

The result is a StoreyLayout resource — a pure data object with no scene nodes. The renderer reads it separately. The generator never touches the scene tree.

Critical architecture rule: ENTRY and TRIGGER are placed in Pass 2b, BEFORE corridor carving in Pass 2c. This guarantees corridors route toward TRIGGER and it is never isolated. Do not move this order.


The Grid

Every storey is a 2D grid of cells. The grid has a bounding rectangle (width × height). Not every cell inside the bounding rect is playable — some are OUT_OF_BOUNDS, which creates the irregular outer shape.

Grid coordinates: (0,0) = top-left, (width-1, height-1) = bottom-right
World position of cell (x, y) = (x * CELL_SIZE, 0, y * CELL_SIZE)
Cell centre = (x * CELL_SIZE + 0.5, 0, y * CELL_SIZE + 0.5)
CELL_SIZE = 1.0 world units

Cell Types

Type Value Meaning Rendered as
OUT_OF_BOUNDS 0 Outside playable shape Nothing — no mesh
WALL 1 Solid — blocks movement Tall box (height 2.0)
EMPTY 2 Open floor — walkable, buildable Flat tile (height 0.1)
ENTRY 3 Enemy spawn point — discrete opening in outer wall Green floor tile
BEACON 4 Communications restoration point Vivid magenta glowing pillar
SHAFT 5 Tube connection point — exactly 2 per storey Blue floor tile
STAIRCASE 6 Up/down transition point Amber floor tile
PATH 7 Not stored in cells — overlay on enemy_path Yellow floor tile

Walkable cells (Genesis and enemies can traverse): EMPTY, ENTRY, BEACON, SHAFT, STAIRCASE

Non-walkable cells: OUT_OF_BOUNDS, WALL

PATH overlay note: The renderer skips ENTRY, BEACON, and STAIRCASE cells when drawing the yellow path overlay — their own colours remain visible so special cells are always identifiable even when on the path.

BEACON notes: - Represents damaged communications infrastructure Genesis must restore - Multiple BEACONs per storey — count scales with storey depth - First BEACON always placed in a dead end or corner cell - Additional BEACONs placed in interior cells at minimum separation distance - Enemies attack BEACON cells — causes bleed if damaged - Each BEACON contributes independently to storey yield (partial progress) - All BEACONs on all storeys restored → RECLAIM BUILDING becomes available


StoreyType

Each storey has a type that affects its dimensions, BSP behaviour, and corridor width. Set via the StoreyType enum in StoreyLayout.gd.

Type Meaning
GROUND_FLOOR First floor of a building — medium size, standard corridors
UPPER_FLOOR Higher floors — slightly smaller, wider corridors, no OOB cuts
UNDERGROUND Factory floor — larger OOB probability, narrower corridors

StoreyType Effects on Generation

Parameter GROUND_FLOOR UPPER_FLOOR UNDERGROUND
OOB probability 35% 0% 55%
Corridor width 2 cells 2 cells 1 cell
Dimension adjustment base −2w −1h base −2w −1h base (full size)

Why UPPER_FLOOR has 0% OOB: Upper floors are contained inside a building. They should fill their footprint — irregular outer shape comes from the building's outer walls, not from missing floor sections.

Why UNDERGROUND has 55% OOB and 1-cell corridors: The underground factory floor needs large open spaces for building placement, but the overall shape should be more irregular and cave-like. Narrower corridors preserve wall mass for factory context.


Architecture — Cells vs Objects vs Assets

Three distinct layers handle what appears in a storey. Each layer has a single responsibility and must not be mixed with the others.

Layer 1 — Cell Types (pure data)

Cell types are integers stored in the grid. They define what a cell IS — not how it looks or how it behaves. Never add visual or gameplay logic to cell type values.

Layer 2 — StoreyRenderer (visuals)

Reads cell types and spawns mesh nodes. Currently uses procedural BoxMesh with hardcoded colours — placeholder only.

Art pass upgrade path: data/outpost/cell_definitions.tres — one resource per cell type references .glb mesh and material StoreyRenderer reads cell_definitions.tres instead of hardcoded values Different outpost themes (rusty industrial, clean tech, alien organic) = different cell_definitions.tres, zero code changes

Layer 3 — StoreyObjectSpawner (interactive objects)

Reads the layout AFTER rendering and spawns scene-based interactive objects at special cell positions. Each special cell type gets its own .tscn scene with state, signals, and interaction logic.

BEACON cell → spawns BeaconObject.tscn (health, restoration progress, damage state, particles) ENTRY cell → spawns EntryPoint.tscn (drone spawner, spawn rate, wave profile reader) STAIRCASE cell → spawns StaircaseObject.tscn (interaction zone, up/down prompt) SHAFT cell → spawns ShaftConnector.tscn (tube connection point, throughput state)

StoreyObjectSpawner is implemented for BEACON and ENTRY cells. STAIRCASE and SHAFT object scene spawning is still pending.

Rule

Cell type = what kind of cell is this (integer, never changes) Renderer = how the cell looks (mesh, material, asset-driven) Object = what the cell does (scene instance, state, signals)

Never put gameplay logic in StoreyRenderer. Never put visual logic in cell type definitions. Never skip StoreyObjectSpawner by putting interaction logic directly in StoreyRenderer.


Pass 1 — Dimensions

Computes width and height from storey_depth, outpost_level, and storey_type.

storey_depth:  1-based index of this storey within its building.
               Deeper storeys = larger, more complex layouts.
outpost_level: 1-4. Higher levels = larger layouts overall.
storey_type:   Applies dimension adjustment on top of base calculation.

Bounds:

MIN_WIDTH  = 16    MAX_WIDTH  = 28
MIN_HEIGHT = 10    MAX_HEIGHT = 20

All cells initialised to OUT_OF_BOUNDS at the end of Pass 1. BSP (Pass 2a) carves the playable shape out of this blank slate.

Why these bounds: - 16×10 minimum gives enough space for meaningful BSP splits (at least 12 leaves with _MIN_PARTITION = 3) - 28×20 maximum keeps generation fast and the layout readable - Going below 16 wide produces too few leaves and rectangular shapes


Pass 2a — BSP Room Creation

Splits the bounding rect recursively. Marks some perimeter partitions as OOB. Converts the rest to rooms filled with EMPTY cells.

Key Parameter: _MIN_PARTITION

const _MIN_PARTITION: int = 3

This is the most important parameter in the generator.

Minimum cell count on each axis before stopping splits.

Value Leaves from 16×12 Result
6 ~6 leaves Too few — rectangular, no OOB cuts fire
4 ~8-10 leaves Better but still boxy
3 ~12-20 leaves Good — enough variety and OOB opportunity
2 ~20-30 leaves Too many — tiny rooms, maze-like

Set to 3. Do not raise above 4 — you will get rectangular outputs.

OOB Decision

if not is_edge_required and is_perimeter and rng.randf() < oob_probability:
    continue  # entire partition stays OUT_OF_BOUNDS

oob_probability comes from StoreyType (35% / 0% / 55%).

Protected partitions (never OOB): - Leftmost two — needed for ENTRY zone width - Rightmost one — needed for TRIGGER placement

Room Inset: _ROOM_SHRINK

const _ROOM_SHRINK: int = 1

Each room is inset 1 cell from its partition boundary. Border cells become WALL. Do not set to 0 (rooms merge) or 2+ (rooms too small).


Pass 2b — Entry and Beacon Placement

ENTRY Points

Discrete openings in the outer wall where drones enter the storey. Not a zone — individual cells placed on the perimeter.

Count scales with storey depth: depth 1 → 1 ENTRY depth 2-3 → 2 ENTRYs depth 4+ → 3 ENTRYs

Placement rules: - Must be adjacent to OUT_OF_BOUNDS or bounding rect edge - Can appear on any side — not just the left edge - Seeded by RNG — direction of threat varies per storey

Minimum distances: From any BEACON: 6 cells (_MIN_ENTRY_BEACON_DIST = 6) From each other: 5 cells (_MIN_ENTRY_SEPARATION = 5) From STAIRCASE: 4 cells (_MIN_ENTRY_STAIR_DIST = 4)

BEACON Cells

Number of BEACONs scales with storey depth:

Storey depth BEACON count
1 1
2-3 2
4+ 3

First BEACON placement — dead end or corner: A dead end cell has exactly 1 walkable neighbour. A corner cell is at the corner of a room (two non-walkable sides adjacent). The generator prefers dead ends, falls back to corners, falls back to any interior cell if neither exists.

Additional BEACONs — interior cells: Placed in any interior EMPTY cell, minimum _MIN_BEACON_SEPARATION cells apart from each other and from the first BEACON.

const _MIN_BEACON_SEPARATION: int = 5

Corridor routing: Corridors in Pass 2c carve toward the FIRST BEACON only. Additional BEACONs are placed in rooms already connected by corridors so they are reachable without additional carving.


Pass 2c — Corridor Carving

Connects consecutive rooms in BSP sort order (left to right by X midpoint) with corridors. Then carves a guaranteed connection from the nearest room centre to TRIGGER.

Corridor width by StoreyType: - GROUND_FLOOR / UPPER_FLOOR: 2 cells wide - UNDERGROUND: 1 cell wide

Trigger connection uses single-cell width (_carve_line_h/v not _carve_band_h/v). This matches TRIGGER's 1-cell size and avoids open wall gaps at the trigger end. Using band carvers for the trigger connection produced visual artefacts — gaps in wall mass around the TRIGGER cell.


Pass 3 — Shaft and Staircase Placement

SHAFT Cells (exactly 2)

Placed on wall-border cells (adjacent to a WALL cell). Must be at least _MIN_SHAFT_SEPARATION apart.

const _MIN_SHAFT_SEPARATION: int = 4

Placing shafts on wall-border cells means they sit at room edges — more realistic tube entry points than open floor centre positions.

STAIRCASE Cell

Single interior cell, not overlapping shaft cells.

Forced staircase support:

static func generate(..., forced_staircase: Vector2i = Vector2i(-1, -1)) -> StoreyLayout

When forced_staircase is a valid position (not -1,-1), the generator places STAIRCASE at that exact cell. Used when generating multi-storey buildings — pass storey 1's staircase position to all subsequent storeys so the staircase aligns vertically.

# Multi-storey chaining example
var s1 := StoreyGenerator.generate(master_seed ^ hash(0), 1, level)
var s2 := StoreyGenerator.generate(master_seed ^ hash(1), 2, level, s1.staircase_cell)
var s3 := StoreyGenerator.generate(master_seed ^ hash(2), 3, level, s1.staircase_cell)

Pass 4 — Path Validation

Runs A from every ENTRY cell to the TRIGGER cell. Implemented in StoreyValidator.gdread-only, never modifies layout.*

const MAX_RETRIES: int = 10

If no path found after MAX_RETRIES — push_warning, leave enemy_path empty. Pass 4 does not carve corridors or modify any cells. All layout work happens in passes 1-3. If validation fails it is a generator bug upstream.

A* Implementation Notes

  • 4-directional movement only (no diagonals)
  • Manhattan distance heuristic: abs(dx) + abs(dy)
  • Walkable = EMPTY, ENTRY, TRIGGER, SHAFT, STAIRCASE
  • WALL and OUT_OF_BOUNDS are impassable
  • Implemented directly on StoreyLayout — no Godot pathfinding nodes

Pass 5 — Annotation

BFS flood fill counts distinct open floor regions. Result stored as layout.open_zone_count.

open_zone_count = 1 — healthy, one connected playable area. open_zone_count > 1 — disconnected regions exist, corridor carving missed a connection somewhere.


StoreyLayout — Full Field Reference

Field Type Set by Meaning
seed_value int Pass 1 The seed used to generate this layout
storey_type StoreyType Pass 1 GROUND_FLOOR / UPPER_FLOOR / UNDERGROUND
width int Pass 1 Grid width in cells
height int Pass 1 Grid height in cells
cells Array Pass 2a 2D grid — cells[x][y] = CellType int
entry_cells Array[Vector2i] Pass 2b Discrete ENTRY point positions — 1 to 3 depending on storey depth
beacon_cells Array[Vector2i] Pass 2b All BEACON positions — 1 to 3 depending on depth
primary_beacon Vector2i Pass 2b First BEACON — always in dead end or corner
shaft_cells Array[Vector2i] Pass 3 Exactly 2 SHAFT positions
staircase_cell Vector2i Pass 3 The single STAIRCASE position
enemy_path Array[Vector2i] Pass 4 A* path ENTRY→TRIGGER, empty if none
open_zone_count int Pass 5 Number of distinct walkable regions

Determinism Guarantee

var rng := RandomNumberGenerator.new()
rng.seed = seed_value

Rules that must not be broken: - Never use global randf() or randi() — always use the local rng - Never add or remove RNG calls without testing determinism - Never change the order of RNG calls between runs - After any generator change, verify: generate the same seed 5 times and confirm identical output every time


Renderer — Colour Reference

Cell type Colour Hex approx
EMPTY floor Light blue #87CEEB
WALL Warm grey #59524A
ENTRY Orange #E88719
BEACON Vivid magenta #FF4DCC
SHAFT Blue #335799
STAIRCASE Amber #8C6619
PATH overlay Yellow #B3A61A
OUT_OF_BOUNDS (nothing)

Debug Inspector — Parameter Guide

Control Range Effect
Seed Any int Changes layout completely — same seed = same layout
Storey Depth 1–6 Higher = larger grid, more complex BSP
Outpost Level 1–4 Higher = larger grid scaling multiplier
StoreyType Dropdown GROUND_FLOOR / UPPER_FLOOR / UNDERGROUND

Entry point from main game: Main menu has a [DEV] Outpost Debug button → loads outpost_test.tscn. Auto-hidden in release exports via OS.is_debug_build().


Bug Fix History

Date Bug Root Cause Fix
2026-03-16 OOB always 0, rectangular output _MIN_PARTITION = 6 — only 6 leaves, 30% OOB never fired on non-protected partitions Changed to 3, raised OOB probability to 45% (then per-StoreyType)
2026-03-16 Enemy path always NONE TRIGGER placed after corridor carving — corridors never routed toward it Split passes: ENTRY/BEACON in Pass 2b before corridors in Pass 2c
2026-03-16 Visual/validator mismatch Corridor carving set cells to EMPTY but validator read stale data Fixed cell state sync between carver and validator
2026-03-16 Open wall gaps at beacon end _carve_band_h/v (2-cell wide) used for beacon connection — width mismatch Changed beacon connection to _carve_line_h/v (1-cell wide)
2026-03-16 Staircase overwritten by path overlay Renderer PATH overlay wrote yellow over STAIRCASE cells _overlay_path() now skips STAIRCASE, ENTRY, and BEACON
2026-03-21 ENTRY zone redesigned Full left-column zone incompatible with dynamic roaming enemies Changed to discrete perimeter openings, 1-3 per storey depth, any side, minimum distance rules from BEACON and STAIRCASE
2026-03-17 TRIGGER renamed to BEACON Design change — storey completion is restoration-based not path-based Renamed CellType.TRIGGER → CellType.BEACON throughout. Multi-beacon placement added. Dead end/corner placement for primary beacon.
2026-03-16 Staircase misaligned across storeys Each storey generated its own staircase independently Added forced_staircase: Vector2i param to generate()

What Is Not Yet Implemented

Feature Notes
~~Outpost building entity~~ ✓ Done 2026-03-16 — OutpostBuilding.gd, 3 storeys stacked at Y=3.0 intervals
~~Staircase chaining~~ ✓ Done 2026-03-16 — forced_staircase param aligns staircase vertically
~~Storey-to-storey transitions~~ ✓ Done 2026-03-16 — preview mode (camera only) + travel mode (Genesis moves when on staircase)
~~Genesis in outpost~~ ✓ Done 2026-03-16 — OutpostGenesis.gd, WASD + click-to-move, flying, health bar
~~Wall collision~~ ✓ Done 2026-03-16 — WALL cells render as StaticBody3D with BoxShape3D
Building entrance cell type Ground floor has no dedicated player entrance — ENTRY is enemy spawn only. Need CellType ENTRANCE or DOOR on ground floor boundary for planet-surface door. Deferred until planet map and outpost placement are designed. Genesis currently spawns at first entry_cell.
Enemy spawning Drones spawn from ENTRY cells, roam toward power sources. Aliens roam toward Genesis (heat). Neither paths to BEACON — they roam dynamically. Next milestone.
BEACON restoration mechanic Genesis walks to BEACON, spends materials, restores it. Each BEACON contributes independently to storey yield. Damaged BEACON increases bleed.
Reclaim Building All BEACONs on all storeys restored → RECLAIM prompt in underground control room. Player optimises before triggering. Once triggered — building locked, trickle begins.
Tower placement Player places towers on EMPTY cells — after enemies exist
Tower zone annotation Tag open zones with recommended height coverage — after tower system
Underground factory entity UNDERGROUND StoreyType generates correctly — building entity not yet built
Surface layer Simpler than storeys — one chunk, seeded salvage nodes
Outpost entry from planet layer Transition from planet map into outpost. Requires building entrance cell type above.
Remove debug print()s Strip before any production use
~~StoreyObjectSpawner (Beacon + Entry)~~ ✓ Done 2026-03-21 — StoreyObjectSpawner.gd spawns BeaconObject and EntryPoint at special cells after render. StaircaseObject/ShaftConnector spawning still pending.

File Locations

outpost/src/systems/generation/
  StoreyLayout.gd      — data contract (CellType + StoreyType enums, fields, helpers)
  StoreyGenerator.gd   — multi-pass generator (all parameters live here)
  StoreyValidator.gd   — A* pathfinder + BFS zone counter (read-only)
  StoreyRenderer.gd    — reads StoreyLayout, spawns 3D mesh nodes
                         WALL cells: StaticBody3D + BoxShape3D + MeshInstance3D
                         Floor cells: MeshInstance3D only (no collision)

outpost/src/systems/outpost/
  OutpostBuilding.gd   — owns N storeys, generates stack, tracks active floor
                         STOREY_HEIGHT = 3.0 units between floors
                         ascend()/descend() with preview vs travel mode

outpost/src/entities/genesis/
  OutpostGenesis.gd    — lightweight flying Genesis for outpost
                         WASD + click-to-move, hover bob, health system
                         Emits reached_staircase / left_staircase signals
                         collision_mask = 1 (collides with wall StaticBody3D nodes)

outpost/src/world/
  OutpostGame.gd       — wires building + genesis + UI
                         Preview mode: camera moves, Genesis stays
                         Travel mode: only active when on_staircase = true

outpost/src/ui/inspector/
  SeedInspector.gd     — debug UI: seed, sliders, StoreyType dropdown, stats, legend

outpost/scenes/
  outpost_test.tscn    — single storey debug workbench (not a game scene)
  outpost_game.tscn    — full game scene: building + genesis + HUD

Update this document whenever a parameter changes, a bug is fixed, or a new feature is implemented. The bug fix history is especially important — it captures why things are the way they are.