7 tasks: quick fixes, relic effects, target selection, turn timer, StartGame race fix, BSP tile map, lobby join-by-code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
16 KiB
Catacombs Bug Fixes & Missing Features Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fix critical bugs and implement missing features identified in the spec-vs-implementation review.
Architecture: Incremental fixes to existing codebase. No major restructuring — patch what's broken, add what's missing. Each task is independently shippable.
Tech Stack: Go 1.22+, charmbracelet/wish, charmbracelet/bubbletea, charmbracelet/lipgloss, go.etcd.io/bbolt
Spec: docs/superpowers/specs/2026-03-23-catacombs-design.md
Review: See "Critical Issues Summary" in conversation history.
Go binary path: /c/Users/98kim/sdk/go1.25.1/bin/go.exe (not in PATH)
File Map (files touched in this plan)
ui/model.go — remove debug, add nickname input, target selection, dead player guard, save ranking
ui/game_view.go — turn timer display, target selection indicator
game/session.go — fix StartGame race, add TurnTimeLeft to GameState, relic effects
game/turn.go — relic effect application on kill, individual flee
game/event.go — overlapping monster floor ranges
entity/player.go — relic-aware EffectiveATK/EffectiveDEF
dungeon/generator.go — BSP dungeon generation with 2D tile map
dungeon/room.go — tile-based room rendering data
dungeon/render.go — NEW: ASCII tile map renderer
store/db.go — no changes needed (SaveRun already exists)
Task 1: Quick Fixes (debug removal, ranking save, nickname, dead player guard)
Files:
-
Modify:
ui/model.go -
Step 1: Remove debug lines from View()
Remove the debug variable and all debug + prefixes from View(). Remove lastKey field from Model struct.
- Step 2: Save ranking on game over
In updateGame, when m.gameState.GameOver is detected and screen transitions to result, call:
if m.store != nil {
score := 0
for _, p := range m.gameState.Players {
score += p.Gold
}
m.store.SaveRun(m.playerName, m.gameState.FloorNum, score)
}
- Step 3: Block dead player actions
In updateGame combat key handling, add guard:
// Find this player and check if dead
isPlayerDead := false
for _, p := range m.gameState.Players {
if p.Name == m.playerName && p.IsDead() {
isPlayerDead = true
break
}
}
if isPlayerDead {
return m, m.pollState() // spectator mode: just keep watching
}
- Step 4: Save nickname on first connection
In updateTitle, after setting m.playerName = "Adventurer" (when profile not found), add:
if m.fingerprint != "" {
m.store.SaveProfile(m.fingerprint, m.playerName)
}
- Step 5: Build and run tests
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./ui/ -v -timeout 15s
- Step 6: Commit
git add ui/model.go
git commit -m "fix: remove debug, save rankings, block dead actions, save nickname"
Task 2: Relic Effects
Files:
-
Modify:
entity/player.go,game/turn.go -
Test:
entity/player_test.go -
Step 1: Write relic effect test
Add to entity/player_test.go:
func TestRelicEffects(t *testing.T) {
p := NewPlayer("test", ClassWarrior)
p.Relics = append(p.Relics, Relic{Name: "Power Amulet", Effect: RelicATKBoost, Value: 3})
if p.EffectiveATK() != 15 { // 12 base + 3 relic
t.Errorf("ATK with relic: got %d, want 15", p.EffectiveATK())
}
p.Relics = append(p.Relics, Relic{Name: "Iron Ward", Effect: RelicDEFBoost, Value: 2})
if p.EffectiveDEF() != 10 { // 8 base + 2 relic
t.Errorf("DEF with relic: got %d, want 10", p.EffectiveDEF())
}
}
- Step 2: Run test (should fail)
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./entity/ -run TestRelicEffects -v
- Step 3: Add relic bonuses to EffectiveATK/EffectiveDEF
In entity/player.go, update EffectiveATK():
func (p *Player) EffectiveATK() int {
atk := p.ATK
for _, item := range p.Inventory {
if item.Type == ItemWeapon {
atk += item.Bonus
}
}
for _, r := range p.Relics {
if r.Effect == RelicATKBoost {
atk += r.Value
}
}
return atk
}
Update EffectiveDEF() similarly for RelicDEFBoost.
- Step 4: Add HealOnKill relic trigger in turn.go
In resolvePlayerActions(), in the "Award gold" loop where m.IsDead() && aliveBeforeTurn[i], add:
// Apply RelicHealOnKill
for _, p := range s.state.Players {
if !p.IsDead() {
for _, r := range p.Relics {
if r.Effect == entity.RelicHealOnKill {
p.Heal(r.Value)
}
}
}
}
And in the gold award loop, apply RelicGoldBoost:
for _, p := range s.state.Players {
if !p.IsDead() {
bonus := 0
for _, r := range p.Relics {
if r.Effect == entity.RelicGoldBoost {
bonus += r.Value
}
}
p.Gold += goldReward + bonus
}
}
- Step 5: Run all tests
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./entity/ ./game/ -v -timeout 15s
- Step 6: Commit
git add entity/player.go entity/player_test.go game/turn.go
git commit -m "feat: apply relic passive effects (ATK/DEF boost, heal on kill, gold boost)"
Task 3: Combat Target Selection & Individual Flee
Files:
-
Modify:
ui/model.go,ui/game_view.go,game/turn.go -
Step 1: Add target cursor to Model
In ui/model.go, add field to Model:
targetCursor int // selected enemy index during combat
- Step 2: Add target selection keys
In updateGame combat phase, add left/right or tab for target cycling:
case "tab":
if len(m.gameState.Monsters) > 0 {
m.targetCursor = (m.targetCursor + 1) % len(m.gameState.Monsters)
}
Update attack/skill to use m.targetCursor:
case "1":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionAttack, TargetIdx: m.targetCursor})
- Step 3: Show target indicator in HUD
In ui/game_view.go renderHUD, add arrow marker to selected target:
marker := " "
if i == targetCursor {
marker = "> "
}
sb.WriteString(enemyStyle.Render(fmt.Sprintf("%s[%d] %s ...", marker, i, m.Name)))
Pass targetCursor through renderGame and renderHUD (add parameter or embed in state).
- Step 4: Change flee to individual
In game/turn.go resolvePlayerActions, replace the unanimous flee block with individual flee:
// Remove the fleeCount/aliveCount block entirely.
// In the per-player action switch, change ActionFlee to:
case ActionFlee:
if combat.AttemptFlee() {
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name))
// In solo, end combat
if s.state.SoloMode {
s.state.Phase = PhaseExploring
return
}
// In multiplayer, mark player as fled (treat as dead for this combat)
// For simplicity: solo flee ends combat, multiplayer flee wastes turn
} else {
s.addLog(fmt.Sprintf("%s failed to flee!", p.Name))
}
- Step 5: Add [Tab] hint to action bar
In ui/game_view.go, update the action line:
sb.WriteString(actionStyle.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target"))
- Step 6: Build and test
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./... -timeout 15s
- Step 7: Commit
git add ui/model.go ui/game_view.go game/turn.go
git commit -m "feat: target selection with Tab, individual flee in solo"
Task 4: Turn Timer Display
Files:
-
Modify:
game/session.go,game/turn.go,ui/game_view.go -
Step 1: Add TurnDeadline to GameState
In game/session.go GameState struct:
TurnDeadline time.Time // when current turn times out
Import "time" in session.go.
- Step 2: Set deadline in RunTurn
In game/turn.go RunTurn(), after creating the timer:
s.state.TurnDeadline = time.Now().Add(TurnTimeout)
After turn resolves (in resolve section):
s.state.TurnDeadline = time.Time{} // clear deadline
- Step 3: Display countdown in HUD
In ui/game_view.go renderHUD, when in combat phase:
if !state.TurnDeadline.IsZero() {
remaining := time.Until(state.TurnDeadline)
if remaining < 0 {
remaining = 0
}
timerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
sb.WriteString(timerStyle.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
sb.WriteString("\n")
}
Import "time" in game_view.go.
- Step 4: Build and test
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
- Step 5: Commit
git add game/session.go game/turn.go ui/game_view.go
git commit -m "feat: display turn countdown timer in combat HUD"
Task 5: Fix StartGame Race & Overlapping Monster Ranges
Files:
-
Modify:
game/session.go,game/event.go -
Step 1: Fix StartGame to only run once
Add started bool field to GameSession:
type GameSession struct {
// ...
started bool
}
Guard StartGame():
func (s *GameSession) StartGame() {
s.mu.Lock()
if s.started {
s.mu.Unlock()
return
}
s.started = true
s.state.SoloMode = len(s.state.Players) == 1
s.mu.Unlock()
s.StartFloor()
go s.combatLoop()
}
- Step 2: Fix monster floor ranges to overlap
In game/event.go spawnMonsters, replace the exclusive switch with weighted random selection from valid types:
func (s *GameSession) spawnMonsters() {
count := 1 + rand.Intn(5)
floor := s.state.FloorNum
s.state.Monsters = make([]*entity.Monster, count)
// Build list of valid monster types for this floor
type floorRange struct {
mt entity.MonsterType
minFloor int
maxFloor int
}
ranges := []floorRange{
{entity.MonsterSlime, 1, 5},
{entity.MonsterSkeleton, 3, 10},
{entity.MonsterOrc, 6, 14},
{entity.MonsterDarkKnight, 12, 20},
}
var valid []entity.MonsterType
for _, r := range ranges {
if floor >= r.minFloor && floor <= r.maxFloor {
valid = append(valid, r.mt)
}
}
if len(valid) == 0 {
valid = []entity.MonsterType{entity.MonsterSlime}
}
for i := 0; i < count; i++ {
mt := valid[rand.Intn(len(valid))]
m := entity.NewMonster(mt, floor)
if s.state.SoloMode {
m.HP = m.HP / 2
if m.HP < 1 {
m.HP = 1
}
m.MaxHP = m.HP
}
s.state.Monsters[i] = m
}
}
- Step 3: Build and test
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./game/ -v -timeout 15s
- Step 4: Commit
git add game/session.go game/event.go
git commit -m "fix: prevent double StartGame, use overlapping monster floor ranges"
Task 6: BSP Dungeon with 2D Tile Map
Files:
- Modify:
dungeon/generator.go,dungeon/room.go - Create:
dungeon/render.go - Modify:
ui/game_view.go - Test:
dungeon/generator_test.go
This is the biggest task. The goal is to replace the list-based room display with a proper 2D ASCII map.
- Step 1: Update Room struct for tile coordinates
In dungeon/room.go, ensure Room has proper tile-space bounds:
type Room struct {
Type RoomType
X, Y int // top-left corner in tile space
W, H int // width, height in tiles
CenterX int
CenterY int
Visited bool
Cleared bool
Neighbors []int
}
- Step 2: Rewrite BSP generator
Replace dungeon/generator.go with proper BSP:
const (
MapWidth = 60
MapHeight = 20
MinRoomW = 8
MinRoomH = 5
)
type bspNode struct {
x, y, w, h int
left, right *bspNode
room *Room
}
func GenerateFloor(floorNum int) *Floor {
root := &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight}
splitBSP(root, 0)
var rooms []*Room
collectRooms(root, &rooms)
// Ensure 5-8 rooms
for len(rooms) < 5 {
// Re-generate if too few
root = &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight}
splitBSP(root, 0)
rooms = nil
collectRooms(root, &rooms)
}
if len(rooms) > 8 {
rooms = rooms[:8]
}
// Assign room types
for i := range rooms {
rooms[i].Type = RandomRoomType()
}
rooms[len(rooms)-1].Type = RoomBoss
// Connect rooms (sibling nodes + extra)
connectBSP(root, rooms)
// Build tile map
tiles := buildTileMap(rooms)
return &Floor{
Number: floorNum,
Rooms: rooms,
CurrentRoom: 0,
Tiles: tiles,
Width: MapWidth,
Height: MapHeight,
}
}
Add Tiles [][]Tile, Width, Height fields to Floor struct.
Define Tile type:
type Tile int
const (
TileWall Tile = iota
TileFloor
TileCorridor
TileDoor
)
Implement splitBSP, collectRooms, connectBSP, buildTileMap functions.
- Step 3: Create tile map renderer
Create dungeon/render.go:
func RenderFloor(floor *Floor, showFog bool) string {
// Render tiles as ASCII: '#' wall, '.' floor, '+' door, '#' corridor
// Mark current room's content
// Apply fog of war: hidden rooms render as space, visited as dim
}
Characters:
-
#wall -
.floor -
+door / corridor -
@player position (center of current room) -
Dmonster (in combat rooms) -
$treasure,Sshop,?event,Bboss -
Step 4: Update game_view.go to use tile renderer
Replace renderMap() in ui/game_view.go:
func renderMap(floor *dungeon.Floor) string {
return dungeon.RenderFloor(floor, true)
}
- Step 5: Update generator_test.go
Update existing tests to work with new generator. Verify:
-
5-8 rooms
-
Exactly 1 boss room
-
All rooms connected
-
Tile map dimensions are MapWidth x MapHeight
-
Player start room (index 0) has floor tiles
-
Step 6: Build and test
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./dungeon/ -v
- Step 7: Commit
git add dungeon/ ui/game_view.go
git commit -m "feat: BSP dungeon generation with 2D ASCII tile map rendering"
Task 7: Lobby Join-by-Code
Files:
-
Modify:
ui/model.go,ui/lobby_view.go -
Step 1: Add join-by-code input mode to lobby
In ui/model.go, add to lobbyState:
type lobbyState struct {
rooms []roomInfo
input string
cursor int
joining bool // true when typing a code
codeInput string // accumulated code chars
}
- Step 2: Handle 'j' key and code input
In updateLobby:
if m.lobbyState.joining {
if isEnter(key) && len(m.lobbyState.codeInput) == 4 {
if err := m.lobby.JoinRoom(m.lobbyState.codeInput, m.playerName); err == nil {
m.roomCode = m.lobbyState.codeInput
m.screen = screenClassSelect
}
m.lobbyState.joining = false
m.lobbyState.codeInput = ""
} else if isKey(key, "esc") {
m.lobbyState.joining = false
m.lobbyState.codeInput = ""
} else if len(key.String()) == 1 && len(m.lobbyState.codeInput) < 4 {
m.lobbyState.codeInput += strings.ToUpper(key.String())
}
return m, nil
}
// existing key handling...
if isKey(key, "j") {
m.lobbyState.joining = true
m.lobbyState.codeInput = ""
}
- Step 3: Show code input in lobby view
In ui/lobby_view.go renderLobby, add:
if state.joining {
inputStr := state.codeInput + strings.Repeat("_", 4-len(state.codeInput))
sb.WriteString(fmt.Sprintf("\nEnter room code: [%s] (Esc to cancel)", inputStr))
}
- Step 4: Build
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
- Step 5: Commit
git add ui/model.go ui/lobby_view.go
git commit -m "feat: lobby join-by-code with J key and 4-char input"