Map System
Map System
Status: Designed Author: user + game-designer Last Updated: 2026-03-28 System Index: #9 — Vertical Slice, Feature Layer
Overview
The Map System generates and manages the branching node map that structures a run. At the start of each run, a map of ~8 nodes is procedurally generated from a seed, creating a branching path with multiple routes to the boss. Each node has a type (Combat, Elite, Rest, Boss) that determines what happens when players visit it. The host selects which path to take. The map provides strategic choice — risk vs. reward routing — and paces the run by controlling encounter frequency and rest opportunities.
Player Fantasy
"We're charting our own path through danger." The map creates a sense of journey and player agency. Choosing between a safe path (more rest stops) and a dangerous one (elite fights for better rewards) is a team discussion. Seeing the boss at the end creates anticipation and dread.
Detailed Design
Core Rules
- The map is generated once at run start using
RunData.run_seedfor deterministic results. - The map is a directed acyclic graph (DAG) — nodes connect forward only, no backtracking.
- The host selects the next node from available connections. Other players see the selection.
- Each node is visited exactly once. After visiting, the party advances and cannot return.
- The map is fully visible from the start — players can see all node types and plan routes.
- The final node is always BOSS. The first node is always COMBAT.
- Map generation and node selection are server-authoritative.
Map Structure (3 Acts × 5 Floors)
Act 1 - The Graveyard (Floors 1-5):
Layer 0: [COMBAT] ──── Layer 1: [COMBAT/ELITE/REST] ──── Layer 2: [SHOP/SHRINE] ──── Layer 3: [COMBAT] ──── Layer 4: [BOSS: Beholder]
Act 2 - The Dicing Hall (Floors 6-10):
Layer 5: [COMBAT] ──── Layer 6: [COMBAT/ELITE/REST/SHOP] ──── Layer 7: [SHOP/SHRINE/MEMORY] ──── Layer 8: [COMBAT] ──── Layer 9: [BOSS: The House]
Act 3 - The Wasteland (Floors 11-15):
Layer 10: [COMBAT] ──── Layer 11: [COMBAT/ELITE/REST/SHOP] ──── Layer 12: [COMBAT] ──── Layer 13: [COMBAT] ──── Layer 14: [BOSS: Shadow Kraken]
↓
[RNG Portal] (if "Defy the Gods")
Each act has 5 floors:
- Floor 1: Always COMBAT (act entrance)
- Floor 2-3: Random nodes (combat/elite/rest/shop/shrine)
- Floor 4: Memory Shrine (Acts 1-2 only) or random
- Floor 5: Guardian BOSS
MapLayout Resource Schema
| Field | Type | Description |
|---|---|---|
nodes | Array[MapNode] | All nodes in the map |
edges | Array[MapEdge] | Connections between nodes |
layer_count | int | Number of layers (15 for 3 acts × 5 floors, index 0-14) |
act | int | Current act (1-3), derived from floor number |
MapNode Schema
| Field | Type | Description |
|---|---|---|
index | int | Unique node identifier |
layer | int | Which layer (0-14 for 15 floors) |
floor | int | Floor number (1-15) |
act | int | Act number (1-3), computed as (floor - 1) / 5 + 1 |
node_type | NodeType enum | COMBAT, ELITE, REST, BOSS, SHOP, SHRINE, EVENT, TRAP, MEMORY_SHRINE |
position | Vector2 | Visual position on the map screen |
visited | bool | Whether the party has been here |
encounter_data | EncounterData | Pre-rolled enemy composition (for COMBAT/ELITE/BOSS) |
is_guardian | bool | Whether this is a guardian boss |
guardian_id | String | "beholder", "house", "shadow_kraken", "rng" |
MapEdge Schema
| Field | Type | Description |
|---|---|---|
from_index | int | Source node |
to_index | int | Destination node |
NodeType Enum
COMBAT— standard enemy encounterELITE— harder enemies, better rewardsREST— heal 30% max HPBOSS— Guardian encounter (Beholder, The House, Shadow Kraken)SHOP— purchase relics, consumablesSHRINE— blessing, upgrade, or atonementMEMORY_SHRINE— collect memory fragment (Acts 1 & 2 only)EVENT— random eventTRAP— hazard encounter
Generation Algorithm (3 Acts × 5 Floors)
generate_map(seed: int, player_count: int) -> MapLayout:
rng = RandomNumberGenerator.new()
rng.seed = seed
map = MapLayout.new()
map.layer_count = 15
GUARDIANS = {
1: "beholder",
2: "house",
3: "shadow_kraken"
}
# Generate 3 acts × 5 floors = 15 layers
for act in range(1, 4):
base_layer = (act - 1) * 5
# Floor 1 of act: always COMBAT (act entrance)
map.add_node(layer=base_layer, floor=base_layer+1, act=act,
node_type=COMBAT)
# Floor 2-3 of act: random nodes
map.add_node(layer=base_layer+1, floor=base_layer+2, act=act,
type=roll_node_type(rng, base_layer+1, act))
map.add_node(layer=base_layer+2, floor=base_layer+3, act=act,
type=roll_node_type(rng, base_layer+2, act))
# Floor 4 of act: MEMORY_SHRINE for Acts 1-2, random for Act 3
if act <= 2:
map.add_node(layer=base_layer+3, floor=base_layer+4, act=act,
node_type=MEMORY_SHRINE, memory_id=act)
else:
map.add_node(layer=base_layer+3, floor=base_layer+4, act=act,
type=roll_node_type(rng, base_layer+3, act))
# Floor 5 of act: BOSS (Guardian)
map.add_node(layer=base_layer+4, floor=base_layer+5, act=act,
node_type=BOSS, guardian_id=GUARDIANS[act], is_guardian=true)
# Generate edges
for layer in range(0, 14):
for node in map.nodes_at(layer):
next_nodes = map.nodes_at(layer + 1)
connect_count = rng.randi_range(1, min(2, next_nodes.size()))
targets = pick_random_subset(next_nodes, connect_count, rng)
for target in targets:
map.add_edge(node.index, target.index)
# Ensure every node has at least 1 incoming edge
for node in map.nodes_at(layer + 1):
if map.incoming_edges(node).is_empty():
source = pick_random(map.nodes_at(layer), rng)
map.add_edge(source.index, node.index)
# Pre-roll encounters
for node in map.nodes:
if node.node_type in [COMBAT, ELITE]:
node.encounter_data = roll_encounter(node.node_type, player_count, rng)
elif node.node_type == BOSS:
node.encounter_data = roll_guardian_encounter(node.guardian_id, player_count, rng)
return map
Node Type Distribution
roll_node_type(rng, layer, act) -> NodeType:
# Floor rules per act:
# - Floor 1 (layer % 5 == 0): always COMBAT
# - Floor 4 (layer % 5 == 3): Act 1-2 = MEMORY_SHRINE, Act 3 = random
# - Floor 5 (layer % 5 == 4): BOSS (handled in generation, not here)
# Regular floors (2, 3, 4):
weights = {
COMBAT: 40,
ELITE: 15,
REST: 15,
SHOP: 10,
SHRINE: 10,
EVENT: 5,
TRAP: 5
}
# Act modifiers
if act == 1: # No shop, trap, event in Act 1
weights = { COMBAT: 50, ELITE: 15, REST: 20, SHRINE: 15 }
elif act == 2: # Introduce shop
weights[SHOP] = 10
# Caps
if elite_count >= 2: weights[ELITE] = 0
if rest_count >= 2: weights[REST] = 0
if shop_count >= 2: weights[SHOP] = 0
return weighted_random(weights, rng)
Expected map composition (15 floors):
- 3 BOSS nodes (1 per act)
- 5-6 COMBAT nodes
- 2 ELITE nodes
- 2 REST nodes
- 2 SHOP nodes (Acts 2-3)
- 2 SHRINE nodes (Acts 1-3)
- 2 MEMORY_SHRINE nodes (Acts 1-2)
Node Selection
get_available_nodes(current_node_index) -> Array[MapNode]:
return map.edges
.filter(e => e.from_index == current_node_index)
.map(e => map.nodes[e.to_index])
select_node(node_index):
# Host only (validated server-side)
assert node_index in get_available_nodes(current_node_index)
current_node_index = node_index
node.visited = true
match node.node_type:
COMBAT, ELITE, BOSS:
game_state_manager.transition(COMBAT, node.encounter_data)
REST:
game_state_manager.transition(REST)
States and Transitions
The Map System's state is simply current_node_index in RunData. No complex state machine — the Game State Manager handles scene transitions.
Interactions with Other Systems
| System | Direction | Interface |
|---|---|---|
| Game State Manager | Bidirectional | GSM transitions to MAP state. Map signals node selection back. current_node_index stored in RunData. |
| Enemy Data & AI | Reads | Encounter tables used during map generation to pre-roll enemies |
| Player Resources | Calls | REST nodes trigger heal(player, floor(max_hp * rest_heal_percent)) |
| Combat System | Provides data | Map provides EncounterData for the selected combat node |
| Map UI | Reads | UI renders the map layout, highlights current node and available paths |
| Networking Layer | Through GSM | Map selection broadcast to all clients |
Formulas
Map Generation Parameters
| Parameter | Value | Rationale |
|---|---|---|
| Total layers | 15 (3 acts × 5 floors) | 3 Guardians per run |
| Nodes per layer | 1-3 | Branching choice |
| Edges per node | 1-2 forward | Creates forks |
| ELITE cap | 2 per run | 1 per act max |
| REST cap | 2 per run | Scarce healing |
| SHOP cap | 2 per run | From Act 2+ |
| MEMORY_SHRINE | 2 (Acts 1-2) | For memory fragments |
| GUARDIANS | 3 | Beholder → The House → Shadow Kraken |
Run Pacing
Typical path (15 floors):
Act 1: COMBAT → COMBAT → REST → MEMORY_SHRINE → BOSS(Beholder)
Act 2: COMBAT → SHOP → ELITE → MEMORY_SHRINE → BOSS(The House)
Act 3: COMBAT → REST → SHOP → COMBAT → BOSS(Shadow Kraken)
↓
[RNG Portal] (if "Defy")
Time estimate (~25 min):
Combat: ~2 min × 7 = 14 min
Elite: ~4 min × 2 = 8 min
Boss: ~3 min × 3 = 9 min
Rest/Shop/Shrine: ~1 min
Map selection: ~2 min
Total: ~25 min per run
Typical path (15 floors):
Act 1: COMBAT → REST → BOSS(Beholder)
Act 2: COMBAT → SHOP → BOSS(The House)
Act 3: COMBAT → ELITE → BOSS(Shadow Kraken)
Act 4: COMBAT → SHRINE → BOSS(The Dealer)
Act 5: COMBAT → COMBAT → BOSS(RNG)
Time estimate: Combat: ~2 min × 7 = 14 min Elite: ~4 min × 2 = 8 min Boss: ~5 min × 5 = 25 min Rest/Shop/Shrine: ~30 sec × 6 = 3 min Map selection: ~15 sec × 9 = 2.25 min
Total: ~50-55 min per run
## Edge Cases
| Case | Resolution |
|------|------------|
| Only 1 node in a layer | No choice — single path forward. Still valid. |
| All paths lead through ELITE | Valid — player cannot avoid it. Map generation randomness can create this. |
| No REST nodes generated | Valid — rare but possible. Makes the run harder. |
| Map seed collision (two runs generate identical maps) | Fine — map content is the same but card rewards, enemy rolls within encounters, and player decisions differ. |
| Host disconnects during map selection | New host gains selection privilege. Map state unchanged. |
| Player selects node that's not connected to current | Server rejects. Only valid connected nodes are selectable. |
| Run starts with current_node_index = -1 | First node selection transitions to layer 0 (always 1 COMBAT node, auto-selected). |
## Dependencies
### Upstream
| System | Dependency Type | Interface |
|--------|----------------|-----------|
| Game State Manager | Hard | Stores current_node_index in RunData, handles scene transitions |
| Enemy Data & AI | Soft | Encounter tables for pre-rolling combat nodes |
### Downstream
| System | Dependency Type | Interface |
|--------|----------------|-----------|
| Combat System | Hard | Provides EncounterData for selected combat nodes |
| Player Resources | Soft | REST nodes trigger healing |
| Map UI | Hard | UI renders the generated map |
### Contracts
- The map is generated once and stored in RunData. It never changes during a run.
- Node selection is host-only, server-validated.
- Encounter data is pre-rolled at map generation — no randomness at encounter start (deterministic from seed).
## Tuning Knobs
| Knob | Default | Safe Range | Affects |
|------|---------|------------|---------|
| `layer_count` | 8 | 5-12 | Run length |
| `nodes_per_layer_min` | 1 | 1-2 | Branching minimum |
| `nodes_per_layer_max` | 3 | 2-4 | Branching maximum |
| `edges_per_node_max` | 2 | 1-3 | Path options |
| `elite_cap` | 1 | 0-3 | Elite frequency |
| `rest_cap` | 2 | 0-3 | Healing opportunities |
| `combat_weight` | 55 | 40-70 | Combat frequency |
| `elite_weight` | 15 | 5-25 | Elite chance per node |
| `rest_weight` | 30 | 15-40 | Rest chance per node |
| `rest_heal_percent` | 0.30 | 0.15-0.50 | Rest value |
**Interaction warning**: `rest_cap` and `rest_heal_percent` together control total available healing per run. At cap=2, heal=0.30: max healing = 36 HP (from 60 max). Reducing either makes runs significantly harder.
## Acceptance Criteria
| # | Criterion | Verification |
|---|-----------|-------------|
| 1 | Map generates with correct layer count | Unit test: generate map, assert 8 layers |
| 2 | Layer 0 is always COMBAT, layer 7 is always BOSS | Unit test: generate 100 maps, assert first=COMBAT, last=BOSS |
| 3 | Every node is reachable from layer 0 | Unit test: BFS from layer 0, assert all nodes visited |
| 4 | Same seed produces identical map | Unit test: generate twice with same seed, assert identical |
| 5 | ELITE cap respected (max 1) | Unit test: generate 100 maps, assert elite_count ≤ 1 |
| 6 | REST cap respected (max 2) | Unit test: generate 100 maps, assert rest_count ≤ 2 |
| 7 | No ELITE or REST in layers 0-1 | Unit test: generate 100 maps, assert no elite/rest in early layers |
| 8 | Node selection only allows connected nodes | Unit test: attempt to select unconnected node → rejected |
| 9 | Host-only selection enforced | Integration test: non-host sends select_node → rejected |
| 10 | Encounter data pre-rolled matches node type | Unit test: COMBAT node has basic enemies, ELITE has elite, BOSS has boss |