17 KiB
Spire Climbing System — Design Spec
Describes the full lifecycle of a spire run: entering, climbing room-by-room, clearing floors, descending, and exiting.
1. Objective
The Spire is the core progression loop of Mana Loop. The player enters at a starting
floor determined by their spireKey prestige level, clears rooms by casting spells
at enemies, advances floor by floor to ever-higher challenges, and must fully descend
back to the exit floor before they can leave.
Design goals:
- Each floor is a multi-room dungeon with variable room counts.
- The descent is a meaningful mini-game: the player re-traverses every room they climbed in reverse, with each individual room having a 50% independent chance to have reset its enemies.
- Climbing rewards (insight, pacts, loot, discipline XP) are gated behind reaching high floors and signing pacts with guardians.
2. Controls / API
2.1 Player Actions
| Action | Trigger | Effect |
|---|---|---|
| Enter Spire | UI button on Spire Summary tab | enterSpireMode() — init spire state |
| Climb Up | automatic after room is cleared (ascending) | advanceRoomOrFloor() |
| Start Descent | "Descend" button on the climb page | enterDescentMode() — snapshots peak, begins reverse traversal |
| Exit Spire | "Exit" button (only at exit floor R0 during descent) | exitSpireMode() — reset to outside-spire state |
2.2 Game Commands (Store Actions)
The following are the necessary new store actions. Actions already implemented that need modification are noted separately.
| Command | Store | Description |
|---|---|---|
enterSpireMode() |
combatStore | Reset to starting floor R0, generate first room, enter spire mode |
exitSpireMode() |
combatStore | Leave spire, reset all run state |
enterDescentMode() |
combatStore | NEW — snapshot peak floor/room, set climbDirection = 'down' |
advanceRoomOrFloor() |
combatStore | NEW — move to next room/floor (ascending) or previous room/floor (descending) |
processCombatTick(...) |
combatStore | MODIFY — must become room-aware (see §4.4) |
Removed vs. original draft:
skipClearedRoom,markFloorReset,setCurrentRoom,setClearedFloor, andinitGuardianDefensiveStateare not needed as separate public actions — this logic lives insideadvanceRoomOrFloor()andprocessCombatTick()as private helpers.addActivityLogalready exists.
2.3 State Transitions
outside-spire
│ enterSpireMode()
▼
climbing-up (startFloor R0)
│ room cleared → advanceRoomOrFloor() → next room
│ last room on floor cleared → next floor, R0
│ player presses "Descend"
▼
descending (peak floor, peak room)
│ room cleared or skipped → advanceRoomOrFloor() → prev room
│ R0 of floor → prev floor, last room
│ reach exit floor R0
▼
descent complete — "Exit Spire" button shown
│ exitSpireMode()
▼
outside-spire
3. Project Layout
Files to create or modify:
docs/specs/
spire-climbing-spec.md ← this file
spire-combat-spec.md ← companion: spell damage, weapons, golems
src/lib/game/stores/
combat-state.types.ts — add currentRoomIndex, roomsPerFloor, descentPeak,
roomResetState, exitFloor fields
combatStore.ts — add enterDescentMode(), advanceRoomOrFloor()
combat-actions.ts — make processCombatTick room-aware
src/lib/game/utils/
spire-utils.ts — ensure getRoomsForFloor accepts a seed
room-utils.ts — add generateSpireRoomType(), library XP helper
src/components/game/tabs/
SpireCombatPage/
SpireCombatPage.tsx — wire room-cleared; add descent UI
SpireHeader.tsx — "Descend" button on ascent; "Exit" button at exit floor R0
RoomDisplay.tsx — show "Room X / Y", room type badge, current game time
SpireActivityLog.tsx — log all room/floor events
4. Detailed Mechanics
4.1 Entering the Spire
- Player presses "Enter Spire" on the Spire Summary tab.
enterSpireMode()runs:spireMode = truecurrentAction = 'climb'startFloor = 1 + (spireKey × 2)— prestige upgrade; spireKey 0 → F1, spireKey 1 → F3, etc.exitFloor = startFloor— the floor the player must reach on descent to be allowed to exitcurrentFloor = startFloorcurrentRoomIndex = 0roomsPerFloor = getRoomsForFloor(currentFloor, seed)currentRoom = generateSpireFloorState(currentFloor, 0, roomsPerFloor)clearedRooms = {}— tracks whichfloor:roomIndexpairs have been clearedclimbDirection = 'up'descentPeak = nullroomResetState = {}— per-room reset rolls, lazily populated on descent- activity log:
"Entered the Spire at Floor ${startFloor}"
4.2 Room Count Per Floor
getRoomsForFloor(floor, seed):
if isGuardianFloor(floor): return 1
base = 5
floorBonus = min(10, floor / 20) // slow scaling, max +10
randomVariation = floor(seededRandom(seed) * 3) // 0, 1, or 2
return base + floorBonus + randomVariation // range: 5–17
- Guardian floors (every 10th): exactly 1 room.
- All other floors: 5–17 rooms, scaling slowly with floor level.
- Room count is deterministic per floor via seed so the same count is reproduced
on descent. Seed =
floor × 12345 + runId.
4.3 Room Types
Generated by generateSpireRoomType(floor, roomIndex, totalRooms).
Base roll (every room):
roll = seededRandom(floor, roomIndex)
if roll < 0.10: → rare roll (see below)
elif roll < 0.22: → 'swarm'
elif roll < 0.32: → 'speed'
else: → 'combat' (~68% of rooms)
Rare roll (~10% of rooms) — secondary roll determines sub-type:
rareRoll = seededRandom(floor, roomIndex, 'rare')
if rareRoll < 0.40: → 'recovery'
elif rareRoll < 0.70: → 'treasure'
else: → 'library'
So across all rooms: ~40% of 10% = ~4% recovery, ~30% of 10% = ~3% treasure, ~30% of 10% = ~3% library.
Override rules (applied after base roll):
- Last room on a guardian floor → always
'guardian' - Every 7th floor, one room (chosen by seed) → always
'puzzle'
Room type summary:
| Type | Approx. Frequency | Description |
|---|---|---|
combat |
~68% | Single enemy, normal stats |
swarm |
~12% | 3–7 weak enemies |
speed |
~10% | Single enemy with elevated dodge chance |
guardian |
Every 10th floor, 1 room | Boss — high HP, shield, barrier, health regen |
recovery |
~4% | No enemies; restores a portion of current mana pool |
treasure |
~3% | No enemies; grants loot / equipment drop |
library |
~3% | No enemies; grants discipline XP scaled to current floor (see §4.8) |
puzzle |
~1 per 7 floors | Attunement-based challenge; no combat |
Speed room interaction: A speed room combined with an enemy that also has the
agile modifier results in an additive dodge bonus on top of the agile modifier
value. See combat spec §2.3 for modifier details.
4.4 Ascending — Room and Floor Advancement
Rooms advance automatically when all enemies in the current room reach 0 HP (or immediately for non-combat rooms). The player does not press a button.
advanceRoomOrFloor() [direction = 'up']:
markRoomCleared(currentFloor, currentRoomIndex)
activityLog("Room ${currentRoomIndex + 1}/${roomsPerFloor} cleared")
if currentRoomIndex + 1 >= roomsPerFloor:
// Last room on this floor
activityLog("Floor ${currentFloor} cleared — ascending")
newFloor = min(currentFloor + 1, FLOOR_CAP)
currentFloor = newFloor
currentRoomIndex = 0
roomsPerFloor = getRoomsForFloor(newFloor, seed)
currentRoom = generateSpireFloorState(newFloor, 0, roomsPerFloor)
resetCastProgress()
else:
currentRoomIndex += 1
currentRoom = generateSpireFloorState(currentFloor, currentRoomIndex, roomsPerFloor)
resetCastProgress()
Non-combat rooms (recovery, treasure, library, puzzle) trigger advanceRoomOrFloor()
immediately on entry after applying their effect.
4.5 Descent Initiation
The "Descend" button is available at any point during ascent. Pressing it:
enterDescentMode():
descentPeak = { floor: currentFloor, roomIndex: currentRoomIndex }
climbDirection = 'down'
activityLog("Beginning descent from Floor ${currentFloor}, Room ${currentRoomIndex + 1}")
// Start descending from the current room (player re-fights or skips it)
onEnterRoomDescend()
4.6 Descending — Reverse Traversal
On descent, rooms are visited in strict reverse order: within a floor, rooms count down from the highest index back to 0. When room 0 is cleared or skipped, the player moves down to the previous floor at its highest room index.
advanceRoomOrFloor() [direction = 'down']:
activityLog("Room ${currentRoomIndex + 1} passed")
if currentFloor <= exitFloor && currentRoomIndex <= 0:
// Reached the exit point
isDescentComplete = true
activityLog("Descent complete — Exit Spire is now available")
return
if currentRoomIndex <= 0:
// Move down to previous floor, enter at its last room
currentFloor -= 1
roomsPerFloor = getRoomsForFloor(currentFloor, seed)
currentRoomIndex = roomsPerFloor - 1
activityLog("Descended to Floor ${currentFloor}")
else:
currentRoomIndex -= 1
currentRoom = generateSpireFloorState(currentFloor, currentRoomIndex, roomsPerFloor)
resetCastProgress()
onEnterRoomDescend()
4.7 Per-Room Reset on Descent
Each room is checked independently when the player enters it during descent. Floors do not share a single reset roll — every room rolls on its own.
onEnterRoomDescend():
key = `${currentFloor}:${currentRoomIndex}`
if roomResetState[key] is undefined:
roomResetState[key] = (Math.random() < 0.5)
if !wasRoomCleared(currentFloor, currentRoomIndex):
// Room was never cleared on the way up — must fight it now
activityLog("Room ${currentRoomIndex + 1} on Floor ${currentFloor} was not cleared — enemies present")
// enemies already in currentRoom from generation, no change needed
return
if roomResetState[key] === true:
// Room reset — re-generate enemies
currentRoom = generateSpireFloorState(currentFloor, currentRoomIndex, roomsPerFloor)
activityLog("Room ${currentRoomIndex + 1} on Floor ${currentFloor} has reset — enemies respawned")
else:
// Room did not reset — auto-skip
activityLog("Room ${currentRoomIndex + 1} on Floor ${currentFloor} is clear — moving on")
advanceRoomOrFloor() // immediately continue
Guardian rooms that reset on descent re-initialize the full guardian defensive state (shield pool, barrier %, health regen) as if the player is fighting the guardian for the first time.
4.8 Library Rooms — Discipline XP
When a library room is entered:
onEnterLibraryRoom(floor):
discipline = pickRandom(allActiveDisciplines)
xpGrant = BASE_LIBRARY_XP × (1 + floor / 10)
discipline.addXP(xpGrant)
activityLog("Ancient tome studied — ${discipline.name} gained ${xpGrant} XP")
advanceRoomOrFloor()
BASE_LIBRARY_XPis a tunable constant (suggested starting value: 50).- XP is granted to a random active discipline (one of the player's currently running disciplines).
- If no disciplines are active, XP is granted to a random unlocked discipline.
- The grant scales linearly with floor: floor 10 = 2×, floor 20 = 3×, etc.
4.9 Exiting the Spire
The "Exit Spire" button is visible only when:
isDescentComplete === true
(Internally this means currentFloor === exitFloor && currentRoomIndex === 0 && climbDirection === 'down'.)
exitSpireMode():
spireMode = false
currentAction = 'meditate'
climbDirection = null
descentPeak = null
roomResetState = {}
clearedRooms = {}
currentFloor = exitFloor
currentRoomIndex = 0
isDescentComplete = false
activityLog("Exited the Spire")
5. Activity Log Events
Every meaningful state change appends an entry to the spire activity log. Required events:
| Event | Message |
|---|---|
| Enter spire | "Entered the Spire at Floor {N}" |
| Room cleared (combat) | "Floor {N} Room {R}/{total} cleared" |
| Room skipped (no reset) | "Floor {N} Room {R} is clear — moving on" |
| Room reset on descent | "Floor {N} Room {R} has reset — enemies respawned" |
| Room not cleared on ascent | "Floor {N} Room {R} was not cleared — enemies present" |
| Floor ascended | "Ascending to Floor {N}" |
| Floor descended | "Descended to Floor {N}" |
| Non-combat room entered | "Entered {roomType} room on Floor {N}" |
| Library XP granted | "{Discipline} gained {XP} XP from ancient tome" |
| Descent initiated | "Beginning descent from Floor {N} Room {R}" |
| Descent complete | "Descent complete — Exit Spire is now available" |
| Exit spire | "Exited the Spire" |
6. State Fields Summary
New and modified fields in combat-state.types.ts:
// Run identity
startFloor: number // floor entered at (= 1 + spireKey × 2)
exitFloor: number // floor player must reach to exit (= startFloor)
// Room navigation
currentRoomIndex: number // 0-indexed room within currentFloor
roomsPerFloor: number // total rooms on currentFloor (deterministic)
// Descent tracking
climbDirection: 'up' | 'down' | null
descentPeak: { floor: number; roomIndex: number } | null
roomResetState: Record<string, boolean> // key = "floor:roomIndex"
clearedRooms: Record<string, boolean> // key = "floor:roomIndex"
isDescentComplete: boolean
isDescending: boolean(legacy alias) can be removed in favour ofclimbDirection === 'down'.
7. Code Style Notes
- Room count uses the same deterministic seed on descent as ascent:
seed = floor × 12345 + runId. roomResetStateandclearedRoomsuse composite string keys ("floor:roomIndex") to avoid nested object complexity.- Descent-related state is not persisted — a page reload mid-descent forfeits the run.
- All activity log calls go through the existing
addActivityLog(type, msg, details)action.
8. Testing
Unit Tests
getRoomsForFloor— same output for same (floor, seed); returns 1 for guardian floors.generateSpireRoomType— rare roll produces recovery/treasure/library at correct ratios; guardian floor override works; puzzle floor override works.advanceRoomOrFloorascending — increments roomIndex; on last room, increments floor and resets roomIndex to 0.advanceRoomOrFloordescending — decrements roomIndex; at roomIndex 0, moves to previous floor atroomsPerFloor - 1; at exitFloor R0, setsisDescentComplete.- Per-room reset — each room rolls independently; two rooms on the same floor can have different outcomes.
- Library XP — grant scales with floor; targets an active discipline.
spireKey—startFloorandexitFloorcorrectly reflect1 + spireKey × 2.
Integration Tests
- Full ascent then descent — player reaches F3 R4, starts descent, verifies F3 R4→R3→R2→R1→R0, then F2 last_room→...→R0, then F1 last_room→...→R0 (if startFloor = F1).
- Per-room reset independence — mock random so room 0 resets and room 1 does not on the same floor.
- Exit gating — "Exit Spire" not visible until
isDescentCompleteis true.
9. Boundaries / Out of Scope
- Loot generation inside treasure rooms (drop tables are a separate system).
- Golem summoning lifecycle (see combat spec §6).
- DoT / debuff runtime processing (see combat spec §5).
- Incursion's effect on mana regen during spire (handled in manaStore, not here).
- Auto-climb / auto-descend automation.
- Per-floor rewards (insight, mana drops) — handled by
onFloorClearedin combat-tick.
10. Acceptance Criteria
| # | Criterion |
|---|---|
| AC-1 | spireKey 0 starts at F1; spireKey 1 starts at F3; spireKey 2 starts at F5. |
| AC-2 | Entering spire starts at startFloor R0; rooms advance automatically on clear. |
| AC-3 | Each room shows "Room X / Y" and the room type in the UI. |
| AC-4 | After clearing last room on floor N, player moves to F(N+1) R0 with new room count. |
| AC-5 | "Descend" button is available at any point during ascent. |
| AC-6 | Descent traverses rooms in exact reverse (R_max → R0 per floor, then floor-1). |
| AC-7 | Each room on descent rolls its reset independently (50%); two rooms on the same floor can differ. |
| AC-8 | Skipped rooms (no reset) log an activity entry and auto-advance immediately. |
| AC-9 | Library rooms grant discipline XP scaled by floor to a random active discipline and log the event. |
| AC-10 | "Exit Spire" is only visible when isDescentComplete === true. |
| AC-11 | Guardian rooms that reset on descent re-initialize full guardian defensive state. |
| AC-12 | Activity log contains an entry for every room skip, reset, clear, floor transition, and spire entry/exit. |