DesignGddMap 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

  1. The map is generated once at run start using RunData.run_seed for deterministic results.
  2. The map is a directed acyclic graph (DAG) — nodes connect forward only, no backtracking.
  3. The host selects the next node from available connections. Other players see the selection.
  4. Each node is visited exactly once. After visiting, the party advances and cannot return.
  5. The map is fully visible from the start — players can see all node types and plan routes.
  6. The final node is always BOSS. The first node is always COMBAT.
  7. 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

FieldTypeDescription
nodesArray[MapNode]All nodes in the map
edgesArray[MapEdge]Connections between nodes
layer_countintNumber of layers (15 for 3 acts × 5 floors, index 0-14)
actintCurrent act (1-3), derived from floor number

MapNode Schema

FieldTypeDescription
indexintUnique node identifier
layerintWhich layer (0-14 for 15 floors)
floorintFloor number (1-15)
actintAct number (1-3), computed as (floor - 1) / 5 + 1
node_typeNodeType enumCOMBAT, ELITE, REST, BOSS, SHOP, SHRINE, EVENT, TRAP, MEMORY_SHRINE
positionVector2Visual position on the map screen
visitedboolWhether the party has been here
encounter_dataEncounterDataPre-rolled enemy composition (for COMBAT/ELITE/BOSS)
is_guardianboolWhether this is a guardian boss
guardian_idString"beholder", "house", "shadow_kraken", "rng"

MapEdge Schema

FieldTypeDescription
from_indexintSource node
to_indexintDestination node

NodeType Enum

  • COMBAT — standard enemy encounter
  • ELITE — harder enemies, better rewards
  • REST — heal 30% max HP
  • BOSS — Guardian encounter (Beholder, The House, Shadow Kraken)
  • SHOP — purchase relics, consumables
  • SHRINE — blessing, upgrade, or atonement
  • MEMORY_SHRINE — collect memory fragment (Acts 1 & 2 only)
  • EVENT — random event
  • TRAP — 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

SystemDirectionInterface
Game State ManagerBidirectionalGSM transitions to MAP state. Map signals node selection back. current_node_index stored in RunData.
Enemy Data & AIReadsEncounter tables used during map generation to pre-roll enemies
Player ResourcesCallsREST nodes trigger heal(player, floor(max_hp * rest_heal_percent))
Combat SystemProvides dataMap provides EncounterData for the selected combat node
Map UIReadsUI renders the map layout, highlights current node and available paths
Networking LayerThrough GSMMap selection broadcast to all clients

Formulas

Map Generation Parameters

ParameterValueRationale
Total layers15 (3 acts × 5 floors)3 Guardians per run
Nodes per layer1-3Branching choice
Edges per node1-2 forwardCreates forks
ELITE cap2 per run1 per act max
REST cap2 per runScarce healing
SHOP cap2 per runFrom Act 2+
MEMORY_SHRINE2 (Acts 1-2)For memory fragments
GUARDIANS3Beholder → 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 &le; 1 |
| 6 | REST cap respected (max 2) | Unit test: generate 100 maps, assert rest_count &le; 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 |
Built with LogoFlowershow