10-task plan covering: scaffold, entities, combat, dungeon gen, game session, store, TUI views, integration, Docker, polish. Fixes from review: class selection screen, floor advancement, victory condition, item/shop usage, dead monster gold fix, per-player flee logic, combat turn counter, boss relic drops. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
63 KiB
Catacombs Implementation 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: Build Catacombs, a multiplayer co-op roguelike terminal game accessible via SSH, using Go and the Charm stack.
Architecture: Single Go binary monolith. Wish handles SSH connections, each spawning a Bubble Tea TUI program. A central GameSession goroutine per party owns all game state and broadcasts updates to connected players via channels. BoltDB stores rankings and player identities.
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
File Structure
catacombs/
├── main.go # Entrypoint: starts SSH server
├── server/
│ └── ssh.go # Wish SSH config, session creation, key-based identity
├── game/
│ ├── lobby.go # Room listing, creation (4-char code), join, start
│ ├── session.go # GameSession goroutine: state ownership, broadcast, turn loop
│ ├── turn.go # Turn timer (5s), input collection, action resolution
│ └── event.go # Room event dispatch: combat, shop, treasure, random events
├── dungeon/
│ ├── generator.go # BSP dungeon generation per floor
│ ├── room.go # Room types, content placement, probability table
│ └── fov.go # Fog of war: visited/visible/hidden states
├── entity/
│ ├── player.go # Player struct, class stats, inventory, gold
│ ├── monster.go # Monster definitions, stat scaling, AI targeting
│ └── item.go # Items: weapons, armor, consumables, relics
├── combat/
│ └── combat.go # Damage calc, co-op bonus, flee, AoE, boss patterns
├── ui/
│ ├── model.go # Root Bubble Tea model: state machine, input routing
│ ├── title.go # Title screen view
│ ├── lobby_view.go # Lobby view: room list, create/join
│ ├── game_view.go # Dungeon map + HUD + combat UI rendering
│ └── result_view.go # Game over / victory screen, ranking display
├── store/
│ └── db.go # BoltDB: player profiles (key fingerprint → nickname), rankings
├── go.mod
├── go.sum
├── Dockerfile
└── docker-compose.yml
Task 1: Project Scaffold & SSH Server
Files:
-
Create:
main.go,server/ssh.go,go.mod,Dockerfile,docker-compose.yml -
Step 1: Initialize Go module
cd E:/projects/catacombs
go mod init github.com/tolelom/catacombs
- Step 2: Install dependencies
go get github.com/charmbracelet/wish@latest
go get github.com/charmbracelet/bubbletea@latest
go get github.com/charmbracelet/lipgloss@latest
go get go.etcd.io/bbolt@latest
- Step 3: Write SSH server
server/ssh.go — Wish SSH server that accepts all connections, extracts public key fingerprint, creates a Bubble Tea program per session.
package server
import (
"context"
"fmt"
"log"
"time"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/bubbletea"
"github.com/tolelom/catacombs/ui"
)
func Start(host string, port int) error {
s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf("%s:%d", host, port)),
wish.WithHostKeyPath(".ssh/catacombs_host_key"),
wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool {
return true // accept all keys
}),
wish.WithMiddleware(
bubbletea.Middleware(func(s ssh.Session) (bubbletea.Model, []bubbletea.ProgramOption) {
pty, _, _ := s.Pty()
fingerprint := ""
if s.PublicKey() != nil {
fingerprint = ssh.FingerprintSHA256(s.PublicKey())
}
m := ui.NewModel(pty.Window.Width, pty.Window.Height, fingerprint)
return m, []bubbletea.ProgramOption{bubbletea.WithAltScreen()}
}),
),
)
if err != nil {
return fmt.Errorf("could not create server: %w", err)
}
log.Printf("Starting SSH server on %s:%d", host, port)
return s.ListenAndServe()
}
- Step 4: Write entrypoint
main.go:
package main
import (
"log"
"github.com/tolelom/catacombs/server"
)
func main() {
if err := server.Start("0.0.0.0", 2222); err != nil {
log.Fatal(err)
}
}
- Step 5: Write minimal UI model (placeholder)
ui/model.go — just enough to verify SSH works:
package ui
import (
tea "github.com/charmbracelet/bubbletea"
)
type screen int
const (
screenTitle screen = iota
)
type Model struct {
width int
height int
fingerprint string
screen screen
}
func NewModel(width, height int, fingerprint string) Model {
return Model{
width: width,
height: height,
fingerprint: fingerprint,
screen: screenTitle,
}
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
func (m Model) View() string {
return "Welcome to Catacombs!\n\nPress q to quit."
}
- Step 6: Write Dockerfile
FROM golang:1.22-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o catacombs .
FROM alpine:latest
WORKDIR /app
COPY --from=build /app/catacombs .
EXPOSE 2222
CMD ["./catacombs"]
- Step 7: Write docker-compose.yml
services:
catacombs:
build: .
ports:
- "2222:2222"
volumes:
- catacombs-data:/app/data
restart: unless-stopped
volumes:
catacombs-data:
- Step 8: Build and verify
go build -o catacombs .
Expected: binary compiles without errors.
- Step 9: Commit
git add -A
git commit -m "feat: project scaffold with SSH server and placeholder TUI"
Task 2: Entity Definitions (Player, Monster, Item)
Files:
-
Create:
entity/player.go,entity/monster.go,entity/item.go -
Test:
entity/player_test.go,entity/monster_test.go,entity/item_test.go -
Step 1: Write item types first (player depends on Item/Relic types)
entity/item.go:
package entity
type ItemType int
const (
ItemWeapon ItemType = iota
ItemArmor
ItemConsumable
)
type Item struct {
Name string
Type ItemType
Bonus int // ATK bonus for weapons, DEF bonus for armor, HP restore for consumables
Price int
}
type RelicEffect int
const (
RelicHealOnKill RelicEffect = iota
RelicATKBoost
RelicDEFBoost
RelicGoldBoost
)
type Relic struct {
Name string
Effect RelicEffect
Value int
Price int
}
func NewHPPotion() Item {
return Item{Name: "HP Potion", Type: ItemConsumable, Bonus: 30, Price: 20}
}
- Step 2: Write player test
entity/player_test.go:
package entity
import "testing"
func TestNewPlayer(t *testing.T) {
p := NewPlayer("testuser", ClassWarrior)
if p.HP != 120 || p.MaxHP != 120 {
t.Errorf("Warrior HP: got %d, want 120", p.HP)
}
if p.ATK != 12 {
t.Errorf("Warrior ATK: got %d, want 12", p.ATK)
}
if p.DEF != 8 {
t.Errorf("Warrior DEF: got %d, want 8", p.DEF)
}
if p.Gold != 0 {
t.Errorf("Initial gold: got %d, want 0", p.Gold)
}
}
func TestAllClasses(t *testing.T) {
tests := []struct {
class Class
hp, atk, def int
}{
{ClassWarrior, 120, 12, 8},
{ClassMage, 70, 20, 3},
{ClassHealer, 90, 8, 5},
{ClassRogue, 85, 15, 4},
}
for _, tt := range tests {
p := NewPlayer("test", tt.class)
if p.HP != tt.hp || p.ATK != tt.atk || p.DEF != tt.def {
t.Errorf("Class %v: got HP=%d ATK=%d DEF=%d, want HP=%d ATK=%d DEF=%d",
tt.class, p.HP, p.ATK, p.DEF, tt.hp, tt.atk, tt.def)
}
}
}
func TestPlayerTakeDamage(t *testing.T) {
p := NewPlayer("test", ClassWarrior)
p.TakeDamage(30)
if p.HP != 90 {
t.Errorf("HP after 30 dmg: got %d, want 90", p.HP)
}
p.TakeDamage(200)
if p.HP != 0 {
t.Errorf("HP should not go below 0: got %d", p.HP)
}
if !p.IsDead() {
t.Error("Player should be dead")
}
}
- Step 3: Run test to verify it fails
cd E:/projects/catacombs && go test ./entity/ -v
Expected: FAIL — Player type not defined.
- Step 4: Implement player
entity/player.go:
package entity
type Class int
const (
ClassWarrior Class = iota
ClassMage
ClassHealer
ClassRogue
)
func (c Class) String() string {
return [...]string{"Warrior", "Mage", "Healer", "Rogue"}[c]
}
type classStats struct {
HP, ATK, DEF int
}
var classBaseStats = map[Class]classStats{
ClassWarrior: {120, 12, 8},
ClassMage: {70, 20, 3},
ClassHealer: {90, 8, 5},
ClassRogue: {85, 15, 4},
}
type Player struct {
Name string
Fingerprint string
Class Class
HP, MaxHP int
ATK, DEF int
Gold int
Inventory []Item
Relics []Relic
Dead bool
}
func NewPlayer(name string, class Class) *Player {
stats := classBaseStats[class]
return &Player{
Name: name,
Class: class,
HP: stats.HP,
MaxHP: stats.HP,
ATK: stats.ATK,
DEF: stats.DEF,
}
}
func (p *Player) TakeDamage(dmg int) {
p.HP -= dmg
if p.HP <= 0 {
p.HP = 0
p.Dead = true
}
}
func (p *Player) Heal(amount int) {
p.HP += amount
if p.HP > p.MaxHP {
p.HP = p.MaxHP
}
}
func (p *Player) IsDead() bool {
return p.Dead
}
func (p *Player) Revive(hpPercent float64) {
p.Dead = false
p.HP = int(float64(p.MaxHP) * hpPercent)
if p.HP < 1 {
p.HP = 1
}
}
func (p *Player) EffectiveATK() int {
atk := p.ATK
for _, item := range p.Inventory {
if item.Type == ItemWeapon {
atk += item.Bonus
}
}
return atk
}
func (p *Player) EffectiveDEF() int {
def := p.DEF
for _, item := range p.Inventory {
if item.Type == ItemArmor {
def += item.Bonus
}
}
return def
}
- Step 5: Run player tests
go test ./entity/ -run TestNewPlayer -v && go test ./entity/ -run TestAllClasses -v && go test ./entity/ -run TestPlayerTakeDamage -v
Expected: all PASS.
- Step 6: Write monster test
entity/monster_test.go:
package entity
import (
"testing"
"math"
)
func TestMonsterScaling(t *testing.T) {
slime := NewMonster(MonsterSlime, 1)
if slime.HP != 20 || slime.ATK != 5 {
t.Errorf("Slime floor 1: got HP=%d ATK=%d, want HP=20 ATK=5", slime.HP, slime.ATK)
}
slimeF3 := NewMonster(MonsterSlime, 3)
expectedHP := int(math.Round(20 * math.Pow(1.15, 2)))
if slimeF3.HP != expectedHP {
t.Errorf("Slime floor 3: got HP=%d, want %d", slimeF3.HP, expectedHP)
}
}
func TestBossStats(t *testing.T) {
boss := NewMonster(MonsterBoss5, 5)
if boss.HP != 150 || boss.ATK != 15 || boss.DEF != 8 {
t.Errorf("Boss5: got HP=%d ATK=%d DEF=%d, want 150/15/8", boss.HP, boss.ATK, boss.DEF)
}
}
- Step 7: Implement monster
entity/monster.go:
package entity
import "math"
type MonsterType int
const (
MonsterSlime MonsterType = iota
MonsterSkeleton
MonsterOrc
MonsterDarkKnight
MonsterBoss5
MonsterBoss10
MonsterBoss15
MonsterBoss20
)
type monsterBase struct {
Name string
HP, ATK, DEF int
MinFloor int
IsBoss bool
}
var monsterDefs = map[MonsterType]monsterBase{
MonsterSlime: {"Slime", 20, 5, 1, 1, false},
MonsterSkeleton: {"Skeleton", 35, 10, 4, 3, false},
MonsterOrc: {"Orc", 55, 14, 6, 6, false},
MonsterDarkKnight: {"Dark Knight", 80, 18, 10, 12, false},
MonsterBoss5: {"Guardian", 150, 15, 8, 5, true},
MonsterBoss10: {"Warden", 250, 22, 12, 10, true},
MonsterBoss15: {"Overlord", 400, 30, 16, 15, true},
MonsterBoss20: {"Archlich", 600, 40, 20, 20, true},
}
type Monster struct {
Name string
Type MonsterType
HP, MaxHP int
ATK, DEF int
IsBoss bool
TauntTarget bool // is being taunted
TauntTurns int
}
func NewMonster(mt MonsterType, floor int) *Monster {
base := monsterDefs[mt]
scale := 1.0
if !base.IsBoss && floor > base.MinFloor {
scale = math.Pow(1.15, float64(floor-base.MinFloor))
}
hp := int(math.Round(float64(base.HP) * scale))
atk := int(math.Round(float64(base.ATK) * scale))
return &Monster{
Name: base.Name,
Type: mt,
HP: hp,
MaxHP: hp,
ATK: atk,
DEF: base.DEF,
IsBoss: base.IsBoss,
}
}
func (m *Monster) TakeDamage(dmg int) {
m.HP -= dmg
if m.HP < 0 {
m.HP = 0
}
}
func (m *Monster) IsDead() bool {
return m.HP <= 0
}
func (m *Monster) TickTaunt() {
if m.TauntTurns > 0 {
m.TauntTurns--
if m.TauntTurns == 0 {
m.TauntTarget = false
}
}
}
- Step 8: Run all entity tests
go test ./entity/ -v
Expected: all PASS.
- Step 9: Commit
git add entity/
git commit -m "feat: entity definitions — player classes, monsters, items"
Task 3: Combat System
Files:
-
Create:
combat/combat.go -
Test:
combat/combat_test.go -
Step 1: Write combat test
combat/combat_test.go:
package combat
import (
"testing"
"github.com/tolelom/catacombs/entity"
)
func TestCalcDamage(t *testing.T) {
// Warrior ATK=12, skill multiplier 1.0, vs Slime DEF=1
// Expected: max(1, 12*1.0 - 1) * rand(0.85~1.15) = 11 * (0.85~1.15)
// Range: 9~12 (with rounding)
dmg := CalcDamage(12, 1, 1.0)
if dmg < 9 || dmg > 13 {
t.Errorf("Damage out of expected range: got %d, want 9~13", dmg)
}
}
func TestCalcDamageMinimum(t *testing.T) {
// ATK=1 vs DEF=100 → max(1, 1-100) = 1 * rand = 1
dmg := CalcDamage(1, 100, 1.0)
if dmg != 1 {
t.Errorf("Minimum damage: got %d, want 1", dmg)
}
}
func TestCoopBonus(t *testing.T) {
// Two players attacking same target: 2nd gets 10% bonus
attackers := []AttackIntent{
{PlayerATK: 12, TargetIdx: 0, Multiplier: 1.0, IsAoE: false},
{PlayerATK: 15, TargetIdx: 0, Multiplier: 1.0, IsAoE: false},
}
results := ResolveAttacks(attackers, []*entity.Monster{entity.NewMonster(entity.MonsterSlime, 1)})
// Second attacker should have co-op bonus
if !results[1].CoopApplied {
t.Error("Second attacker should get co-op bonus")
}
}
func TestAoENoCoopBonus(t *testing.T) {
attackers := []AttackIntent{
{PlayerATK: 12, TargetIdx: 0, Multiplier: 1.0, IsAoE: false},
{PlayerATK: 20, TargetIdx: -1, Multiplier: 0.8, IsAoE: true}, // fireball
}
monsters := []*entity.Monster{
entity.NewMonster(entity.MonsterSlime, 1),
entity.NewMonster(entity.MonsterSlime, 1),
}
results := ResolveAttacks(attackers, monsters)
// First attacker should NOT get co-op from AoE
if results[0].CoopApplied {
t.Error("AoE should not trigger co-op bonus")
}
}
func TestFleeChance(t *testing.T) {
// Run 100 flee attempts, expect roughly 50% success
successes := 0
for i := 0; i < 100; i++ {
if AttemptFlee() {
successes++
}
}
if successes < 20 || successes > 80 {
t.Errorf("Flee success rate suspicious: %d/100", successes)
}
}
- Step 2: Run test to verify it fails
go test ./combat/ -v
Expected: FAIL — package not defined.
- Step 3: Implement combat
combat/combat.go:
package combat
import (
"math"
"math/rand"
"github.com/tolelom/catacombs/entity"
)
// CalcDamage: max(1, ATK * multiplier - DEF) * random(0.85~1.15)
func CalcDamage(atk, def int, multiplier float64) int {
base := float64(atk)*multiplier - float64(def)
if base < 1 {
base = 1
}
randomFactor := 0.85 + rand.Float64()*0.30 // 0.85 ~ 1.15
return int(math.Round(base * randomFactor))
}
type AttackIntent struct {
PlayerATK int
TargetIdx int // -1 for AoE
Multiplier float64
IsAoE bool
}
type AttackResult struct {
TargetIdx int
Damage int
CoopApplied bool
IsAoE bool
}
func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster) []AttackResult {
// Count single-target attacks per target (for co-op bonus)
targetCount := make(map[int]int)
targetOrder := make(map[int]int) // which attacker index was first
for i, intent := range intents {
if !intent.IsAoE {
targetCount[intent.TargetIdx]++
if _, ok := targetOrder[intent.TargetIdx]; !ok {
targetOrder[intent.TargetIdx] = i
}
}
}
results := make([]AttackResult, len(intents))
for i, intent := range intents {
if intent.IsAoE {
// AoE hits all monsters, no co-op bonus
totalDmg := 0
for _, m := range monsters {
if !m.IsDead() {
dmg := CalcDamage(intent.PlayerATK, m.DEF, intent.Multiplier)
m.TakeDamage(dmg)
totalDmg += dmg
}
}
results[i] = AttackResult{TargetIdx: -1, Damage: totalDmg, IsAoE: true}
} else {
if intent.TargetIdx < 0 || intent.TargetIdx >= len(monsters) {
continue
}
m := monsters[intent.TargetIdx]
dmg := CalcDamage(intent.PlayerATK, m.DEF, intent.Multiplier)
// Co-op bonus: 2+ single-target attackers on same target, 2nd+ gets 10%
coopApplied := false
if targetCount[intent.TargetIdx] >= 2 && targetOrder[intent.TargetIdx] != i {
dmg = int(math.Round(float64(dmg) * 1.10))
coopApplied = true
}
m.TakeDamage(dmg)
results[i] = AttackResult{
TargetIdx: intent.TargetIdx,
Damage: dmg,
CoopApplied: coopApplied,
}
}
}
return results
}
func AttemptFlee() bool {
return rand.Float64() < 0.5
}
// MonsterAI picks a target for a monster
func MonsterAI(m *entity.Monster, players []*entity.Player, turnNumber int) (targetIdx int, isAoE bool) {
// Boss AoE every 3 turns
if m.IsBoss && turnNumber%3 == 0 {
return -1, true
}
// If taunted, attack the taunter
if m.TauntTarget {
for i, p := range players {
if !p.IsDead() && p.Class == entity.ClassWarrior {
return i, false
}
}
}
// 30% chance to target lowest HP player
if rand.Float64() < 0.3 {
minHP := int(^uint(0) >> 1)
minIdx := 0
for i, p := range players {
if !p.IsDead() && p.HP < minHP {
minHP = p.HP
minIdx = i
}
}
return minIdx, false
}
// Default: first alive player (closest)
for i, p := range players {
if !p.IsDead() {
return i, false
}
}
return 0, false
}
- Step 4: Run combat tests
go test ./combat/ -v
Expected: all PASS.
- Step 5: Commit
git add combat/
git commit -m "feat: combat system — damage calc, co-op bonus, flee, monster AI"
Task 4: Dungeon Generation
Files:
-
Create:
dungeon/generator.go,dungeon/room.go,dungeon/fov.go -
Test:
dungeon/generator_test.go,dungeon/room_test.go -
Step 1: Write room types
dungeon/room.go:
package dungeon
import "math/rand"
type RoomType int
const (
RoomCombat RoomType = iota
RoomTreasure
RoomShop
RoomEvent
RoomEmpty
RoomBoss
)
func (r RoomType) String() string {
return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss"}[r]
}
type Room struct {
Type RoomType
X, Y int // position in grid
Width, Height int
Visited bool
Cleared bool
Neighbors []int // indices of connected rooms
}
// RandomRoomType returns a room type based on probability table
// Combat: 45%, Treasure: 15%, Shop: 10%, Event: 15%, Empty: 15%
func RandomRoomType() RoomType {
r := rand.Float64() * 100
switch {
case r < 45:
return RoomCombat
case r < 60:
return RoomTreasure
case r < 70:
return RoomShop
case r < 85:
return RoomEvent
default:
return RoomEmpty
}
}
- Step 2: Write generator test
dungeon/generator_test.go:
package dungeon
import "testing"
func TestGenerateFloor(t *testing.T) {
floor := GenerateFloor(1)
if len(floor.Rooms) < 5 || len(floor.Rooms) > 8 {
t.Errorf("Room count: got %d, want 5~8", len(floor.Rooms))
}
// Must have exactly one boss room
bossCount := 0
for _, r := range floor.Rooms {
if r.Type == RoomBoss {
bossCount++
}
}
if bossCount != 1 {
t.Errorf("Boss rooms: got %d, want 1", bossCount)
}
// All rooms must be connected (reachable from room 0)
visited := make(map[int]bool)
var dfs func(int)
dfs = func(idx int) {
if visited[idx] {
return
}
visited[idx] = true
for _, n := range floor.Rooms[idx].Neighbors {
dfs(n)
}
}
dfs(0)
if len(visited) != len(floor.Rooms) {
t.Errorf("Not all rooms connected: reachable %d / %d", len(visited), len(floor.Rooms))
}
}
func TestRoomTypeProbability(t *testing.T) {
counts := make(map[RoomType]int)
n := 10000
for i := 0; i < n; i++ {
counts[RandomRoomType()]++
}
// Combat should be ~45% (allow 40-50%)
combatPct := float64(counts[RoomCombat]) / float64(n) * 100
if combatPct < 40 || combatPct > 50 {
t.Errorf("Combat room probability: got %.1f%%, want ~45%%", combatPct)
}
}
- Step 3: Run test to verify it fails
go test ./dungeon/ -v
Expected: FAIL.
- Step 4: Implement BSP generator
dungeon/generator.go:
package dungeon
import "math/rand"
type Floor struct {
Number int
Rooms []*Room
CurrentRoom int
}
func GenerateFloor(floorNum int) *Floor {
numRooms := 5 + rand.Intn(4) // 5~8 rooms
rooms := make([]*Room, numRooms)
// Place rooms in a simple grid layout for BSP-like connectivity
for i := 0; i < numRooms; i++ {
rt := RandomRoomType()
rooms[i] = &Room{
Type: rt,
X: (i % 3) * 20,
Y: (i / 3) * 10,
Width: 12 + rand.Intn(6),
Height: 6 + rand.Intn(4),
Neighbors: []int{},
}
}
// Last room is always the boss room
rooms[numRooms-1].Type = RoomBoss
// Connect rooms linearly, then add some extra edges for variety
for i := 0; i < numRooms-1; i++ {
rooms[i].Neighbors = append(rooms[i].Neighbors, i+1)
rooms[i+1].Neighbors = append(rooms[i+1].Neighbors, i)
}
// Add 1-2 extra connections for loops
extras := 1 + rand.Intn(2)
for e := 0; e < extras; e++ {
a := rand.Intn(numRooms)
b := rand.Intn(numRooms)
if a != b && !hasNeighbor(rooms[a], b) {
rooms[a].Neighbors = append(rooms[a].Neighbors, b)
rooms[b].Neighbors = append(rooms[b].Neighbors, a)
}
}
return &Floor{
Number: floorNum,
Rooms: rooms,
CurrentRoom: 0,
}
}
func hasNeighbor(r *Room, idx int) bool {
for _, n := range r.Neighbors {
if n == idx {
return true
}
}
return false
}
- Step 5: Implement FOV
dungeon/fov.go:
package dungeon
type Visibility int
const (
Hidden Visibility = iota
Visited // dimmed
Visible // fully visible
)
func UpdateVisibility(floor *Floor) {
for i, room := range floor.Rooms {
if i == floor.CurrentRoom {
room.Visited = true
}
}
}
func GetRoomVisibility(floor *Floor, roomIdx int) Visibility {
if roomIdx == floor.CurrentRoom {
return Visible
}
if floor.Rooms[roomIdx].Visited {
return Visited
}
return Hidden
}
- Step 6: Run dungeon tests
go test ./dungeon/ -v
Expected: all PASS.
- Step 7: Commit
git add dungeon/
git commit -m "feat: dungeon generation — BSP rooms, room types, fog of war"
Task 5: Game Session & Turn System
Files:
-
Create:
game/session.go,game/turn.go,game/event.go,game/lobby.go -
Test:
game/session_test.go,game/turn_test.go,game/lobby_test.go -
Step 1: Write lobby test
game/lobby_test.go:
package game
import "testing"
func TestCreateRoom(t *testing.T) {
lobby := NewLobby()
code := lobby.CreateRoom("Test Room")
if len(code) != 4 {
t.Errorf("Room code length: got %d, want 4", len(code))
}
rooms := lobby.ListRooms()
if len(rooms) != 1 {
t.Errorf("Room count: got %d, want 1", len(rooms))
}
}
func TestJoinRoom(t *testing.T) {
lobby := NewLobby()
code := lobby.CreateRoom("Test Room")
err := lobby.JoinRoom(code, "player1")
if err != nil {
t.Errorf("Join failed: %v", err)
}
room := lobby.GetRoom(code)
if len(room.Players) != 1 {
t.Errorf("Player count: got %d, want 1", len(room.Players))
}
}
func TestJoinRoomFull(t *testing.T) {
lobby := NewLobby()
code := lobby.CreateRoom("Test Room")
for i := 0; i < 4; i++ {
lobby.JoinRoom(code, "player")
}
err := lobby.JoinRoom(code, "player5")
if err == nil {
t.Error("Should reject 5th player")
}
}
- Step 2: Implement lobby
game/lobby.go:
package game
import (
"fmt"
"math/rand"
"sync"
)
type RoomStatus int
const (
RoomWaiting RoomStatus = iota
RoomPlaying
)
type LobbyRoom struct {
Code string
Name string
Players []string
Status RoomStatus
Session *GameSession
}
type Lobby struct {
mu sync.RWMutex
rooms map[string]*LobbyRoom
}
func NewLobby() *Lobby {
return &Lobby{rooms: make(map[string]*LobbyRoom)}
}
func (l *Lobby) CreateRoom(name string) string {
l.mu.Lock()
defer l.mu.Unlock()
code := generateCode()
for l.rooms[code] != nil {
code = generateCode()
}
l.rooms[code] = &LobbyRoom{
Code: code,
Name: name,
Status: RoomWaiting,
}
return code
}
func (l *Lobby) JoinRoom(code, playerName string) error {
l.mu.Lock()
defer l.mu.Unlock()
room, ok := l.rooms[code]
if !ok {
return fmt.Errorf("room %s not found", code)
}
if len(room.Players) >= 4 {
return fmt.Errorf("room %s is full", code)
}
if room.Status != RoomWaiting {
return fmt.Errorf("room %s already in progress", code)
}
room.Players = append(room.Players, playerName)
return nil
}
func (l *Lobby) GetRoom(code string) *LobbyRoom {
l.mu.RLock()
defer l.mu.RUnlock()
return l.rooms[code]
}
func (l *Lobby) ListRooms() []*LobbyRoom {
l.mu.RLock()
defer l.mu.RUnlock()
result := make([]*LobbyRoom, 0, len(l.rooms))
for _, r := range l.rooms {
result = append(result, r)
}
return result
}
func (l *Lobby) RemoveRoom(code string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.rooms, code)
}
func generateCode() string {
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ"
b := make([]byte, 4)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
- Step 3: Write session/turn test
game/session_test.go:
package game
import (
"testing"
"time"
"github.com/tolelom/catacombs/entity"
)
func TestSessionTurnTimeout(t *testing.T) {
s := NewGameSession()
p := entity.NewPlayer("test", entity.ClassWarrior)
s.AddPlayer(p)
s.StartFloor()
// Don't submit any action, wait for timeout
done := make(chan struct{})
go func() {
s.RunTurn()
close(done)
}()
select {
case <-done:
// Turn completed via timeout
case <-time.After(7 * time.Second):
t.Error("Turn did not timeout within 7 seconds")
}
}
- Step 4: Implement session and turn
game/session.go:
package game
import (
"sync"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
)
type GamePhase int
const (
PhaseExploring GamePhase = iota
PhaseCombat
PhaseShop
PhaseResult
)
type PlayerAction struct {
Type ActionType
TargetIdx int
}
type ActionType int
const (
ActionAttack ActionType = iota
ActionSkill
ActionItem
ActionFlee
ActionWait
)
type GameState struct {
Floor *dungeon.Floor
Players []*entity.Player
Monsters []*entity.Monster
Phase GamePhase
FloorNum int
TurnNum int
CombatTurn int // reset per combat encounter
SoloMode bool
GameOver bool
Victory bool
ShopItems []entity.Item
}
type GameSession struct {
mu sync.Mutex
state GameState
actions map[string]PlayerAction // playerName -> action
actionCh chan playerActionMsg
}
type playerActionMsg struct {
PlayerName string
Action PlayerAction
}
func NewGameSession() *GameSession {
return &GameSession{
state: GameState{
FloorNum: 1,
},
actions: make(map[string]PlayerAction),
actionCh: make(chan playerActionMsg, 4),
}
}
// StartGame determines solo mode from actual player count at game start
func (s *GameSession) StartGame() {
s.mu.Lock()
s.state.SoloMode = len(s.state.Players) == 1
s.mu.Unlock()
s.StartFloor()
}
func (s *GameSession) AddPlayer(p *entity.Player) {
s.mu.Lock()
defer s.mu.Unlock()
s.state.Players = append(s.state.Players, p)
}
func (s *GameSession) StartFloor() {
s.mu.Lock()
defer s.mu.Unlock()
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
s.state.Phase = PhaseExploring
s.state.TurnNum = 0
// Revive dead players at 30% HP
for _, p := range s.state.Players {
if p.IsDead() {
p.Revive(0.30)
}
}
}
func (s *GameSession) GetState() GameState {
s.mu.Lock()
defer s.mu.Unlock()
return s.state
}
func (s *GameSession) SubmitAction(playerName string, action PlayerAction) {
s.actionCh <- playerActionMsg{PlayerName: playerName, Action: action}
}
game/turn.go:
package game
import (
"math/rand"
"time"
"github.com/tolelom/catacombs/combat"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
)
const TurnTimeout = 5 * time.Second
func (s *GameSession) RunTurn() {
s.mu.Lock()
s.state.TurnNum++
s.state.CombatTurn++
s.actions = make(map[string]PlayerAction)
aliveCount := 0
for _, p := range s.state.Players {
if !p.IsDead() {
aliveCount++
}
}
s.mu.Unlock()
// Collect actions with timeout
timer := time.NewTimer(TurnTimeout)
collected := 0
for collected < aliveCount {
select {
case msg := <-s.actionCh:
s.mu.Lock()
s.actions[msg.PlayerName] = msg.Action
s.mu.Unlock()
collected++
case <-timer.C:
goto resolve
}
}
timer.Stop()
resolve:
s.mu.Lock()
defer s.mu.Unlock()
// Default action for players who didn't submit: Wait
for _, p := range s.state.Players {
if !p.IsDead() {
if _, ok := s.actions[p.Name]; !ok {
s.actions[p.Name] = PlayerAction{Type: ActionWait}
}
}
}
s.resolvePlayerActions()
s.resolveMonsterActions()
}
func (s *GameSession) resolvePlayerActions() {
var intents []combat.AttackIntent
// Track which monsters were alive before this turn (for gold awards)
aliveBeforeTurn := make(map[int]bool)
for i, m := range s.state.Monsters {
if !m.IsDead() {
aliveBeforeTurn[i] = true
}
}
// Check if ALL alive players chose flee — only then the party flees
fleeCount := 0
aliveCount := 0
for _, p := range s.state.Players {
if p.IsDead() {
continue
}
aliveCount++
if action, ok := s.actions[p.Name]; ok && action.Type == ActionFlee {
fleeCount++
}
}
if fleeCount == aliveCount && aliveCount > 0 {
if combat.AttemptFlee() {
s.state.Phase = PhaseExploring
return
}
// Flee failed — all fleeing players waste their turn, continue to monster phase
return
}
for _, p := range s.state.Players {
if p.IsDead() {
continue
}
action, ok := s.actions[p.Name]
if !ok {
continue
}
switch action.Type {
case ActionAttack:
intents = append(intents, combat.AttackIntent{
PlayerATK: p.EffectiveATK(),
TargetIdx: action.TargetIdx,
Multiplier: 1.0,
IsAoE: false,
})
case ActionSkill:
switch p.Class {
case entity.ClassWarrior:
// Taunt: mark all monsters to target this warrior
for _, m := range s.state.Monsters {
if !m.IsDead() {
m.TauntTarget = true
m.TauntTurns = 2
}
}
case entity.ClassMage:
intents = append(intents, combat.AttackIntent{
PlayerATK: p.EffectiveATK(),
TargetIdx: -1,
Multiplier: 0.8,
IsAoE: true,
})
case entity.ClassHealer:
if action.TargetIdx >= 0 && action.TargetIdx < len(s.state.Players) {
s.state.Players[action.TargetIdx].Heal(30)
}
case entity.ClassRogue:
// Scout: reveal neighboring rooms
currentRoom := s.state.Floor.Rooms[s.state.Floor.CurrentRoom]
for _, neighborIdx := range currentRoom.Neighbors {
s.state.Floor.Rooms[neighborIdx].Visited = true
}
}
case ActionItem:
// Use first consumable from inventory
for i, item := range p.Inventory {
if item.Type == entity.ItemConsumable {
p.Heal(item.Bonus)
p.Inventory = append(p.Inventory[:i], p.Inventory[i+1:]...)
break
}
}
case ActionFlee:
// Individual flee does nothing if not unanimous (already handled above)
case ActionWait:
// Defensive stance — no action
}
}
if len(intents) > 0 && len(s.state.Monsters) > 0 {
combat.ResolveAttacks(intents, s.state.Monsters)
}
// Award gold only for monsters that JUST died this turn
for i, m := range s.state.Monsters {
if m.IsDead() && aliveBeforeTurn[i] {
goldReward := 5 + s.state.FloorNum
if goldReward > 15 {
goldReward = 15
}
for _, p := range s.state.Players {
if !p.IsDead() {
p.Gold += goldReward
}
}
// Boss kill: drop relic
if m.IsBoss {
s.grantBossRelic()
}
}
}
// Filter out dead monsters
alive := make([]*entity.Monster, 0)
for _, m := range s.state.Monsters {
if !m.IsDead() {
alive = append(alive, m)
}
}
s.state.Monsters = alive
// Check if combat is over
if len(s.state.Monsters) == 0 {
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
// Check if this was the boss room -> advance floor
if s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Type == dungeon.RoomBoss {
s.advanceFloor()
} else {
s.state.Phase = PhaseExploring
}
}
}
func (s *GameSession) advanceFloor() {
if s.state.FloorNum >= 20 {
s.state.Phase = PhaseResult
s.state.Victory = true
s.state.GameOver = true
return
}
s.state.FloorNum++
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
s.state.Phase = PhaseExploring
s.state.CombatTurn = 0
// Revive dead players at 30% HP
for _, p := range s.state.Players {
if p.IsDead() {
p.Revive(0.30)
}
}
}
func (s *GameSession) grantBossRelic() {
relics := []entity.Relic{
{Name: "Vampiric Ring", Effect: entity.RelicHealOnKill, Value: 5, Price: 100},
{Name: "Power Amulet", Effect: entity.RelicATKBoost, Value: 3, Price: 120},
{Name: "Iron Ward", Effect: entity.RelicDEFBoost, Value: 2, Price: 100},
{Name: "Gold Charm", Effect: entity.RelicGoldBoost, Value: 5, Price: 150},
}
for _, p := range s.state.Players {
if !p.IsDead() {
r := relics[rand.Intn(len(relics))]
p.Relics = append(p.Relics, r)
}
}
}
// BuyItem handles shop purchases
func (s *GameSession) BuyItem(playerName string, itemIdx int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.state.Phase != PhaseShop || itemIdx < 0 || itemIdx >= len(s.state.ShopItems) {
return false
}
item := s.state.ShopItems[itemIdx]
for _, p := range s.state.Players {
if p.Name == playerName && p.Gold >= item.Price {
p.Gold -= item.Price
p.Inventory = append(p.Inventory, item)
return true
}
}
return false
}
// LeaveShop exits the shop phase
func (s *GameSession) LeaveShop() {
s.mu.Lock()
defer s.mu.Unlock()
s.state.Phase = PhaseExploring
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
}
func (s *GameSession) resolveMonsterActions() {
if s.state.Phase != PhaseCombat {
return
}
for _, m := range s.state.Monsters {
if m.IsDead() {
continue
}
targetIdx, isAoE := combat.MonsterAI(m, s.state.Players, s.state.CombatTurn)
if isAoE {
// Boss AoE: 0.5x damage to all
for _, p := range s.state.Players {
if !p.IsDead() {
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 0.5)
p.TakeDamage(dmg)
}
}
} else {
if targetIdx >= 0 && targetIdx < len(s.state.Players) {
p := s.state.Players[targetIdx]
if !p.IsDead() {
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
p.TakeDamage(dmg)
}
}
}
m.TickTaunt()
}
// Check party wipe
allPlayersDead := true
for _, p := range s.state.Players {
if !p.IsDead() {
allPlayersDead = false
break
}
}
if allPlayersDead {
s.state.Phase = PhaseResult
}
}
game/event.go:
package game
import (
"math/rand"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
)
func (s *GameSession) EnterRoom(roomIdx int) {
s.mu.Lock()
defer s.mu.Unlock()
s.state.Floor.CurrentRoom = roomIdx
dungeon.UpdateVisibility(s.state.Floor)
room := s.state.Floor.Rooms[roomIdx]
if room.Cleared {
return
}
switch room.Type {
case dungeon.RoomCombat:
s.spawnMonsters()
s.state.Phase = PhaseCombat
s.state.CombatTurn = 0
case dungeon.RoomBoss:
s.spawnBoss()
s.state.Phase = PhaseCombat
s.state.CombatTurn = 0
case dungeon.RoomShop:
s.generateShopItems()
s.state.Phase = PhaseShop
case dungeon.RoomTreasure:
s.grantTreasure()
room.Cleared = true
case dungeon.RoomEvent:
s.triggerEvent()
room.Cleared = true
case dungeon.RoomEmpty:
room.Cleared = true
}
}
func (s *GameSession) spawnMonsters() {
count := 1 + rand.Intn(5) // 1~5 monsters
floor := s.state.FloorNum
s.state.Monsters = make([]*entity.Monster, count)
// Pick appropriate monster type for floor
var mt entity.MonsterType
switch {
case floor <= 5:
mt = entity.MonsterSlime
case floor <= 10:
mt = entity.MonsterSkeleton
case floor <= 14:
mt = entity.MonsterOrc
default:
mt = entity.MonsterDarkKnight
}
// Solo mode: 50% HP
for i := 0; i < count; i++ {
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
}
}
func (s *GameSession) spawnBoss() {
var mt entity.MonsterType
switch s.state.FloorNum {
case 5:
mt = entity.MonsterBoss5
case 10:
mt = entity.MonsterBoss10
case 15:
mt = entity.MonsterBoss15
case 20:
mt = entity.MonsterBoss20
default:
mt = entity.MonsterBoss5
}
boss := entity.NewMonster(mt, s.state.FloorNum)
if s.state.SoloMode {
boss.HP = boss.HP / 2
boss.MaxHP = boss.HP
}
s.state.Monsters = []*entity.Monster{boss}
}
func (s *GameSession) grantTreasure() {
// Random item for each player
for _, p := range s.state.Players {
if rand.Float64() < 0.5 {
p.Inventory = append(p.Inventory, entity.Item{
Name: "Iron Sword", Type: entity.ItemWeapon, Bonus: 3 + rand.Intn(6),
})
} else {
p.Inventory = append(p.Inventory, entity.Item{
Name: "Iron Shield", Type: entity.ItemArmor, Bonus: 2 + rand.Intn(4),
})
}
}
}
func (s *GameSession) generateShopItems() {
s.state.ShopItems = []entity.Item{
{Name: "HP Potion", Type: entity.ItemConsumable, Bonus: 30, Price: 20},
{Name: "Iron Sword", Type: entity.ItemWeapon, Bonus: 3 + rand.Intn(6), Price: 40 + rand.Intn(41)},
{Name: "Iron Shield", Type: entity.ItemArmor, Bonus: 2 + rand.Intn(4), Price: 30 + rand.Intn(31)},
}
}
func (s *GameSession) triggerEvent() {
// Random event: 50% trap, 50% blessing
for _, p := range s.state.Players {
if p.IsDead() {
continue
}
if rand.Float64() < 0.5 {
// Trap: 10~20 damage
dmg := 10 + rand.Intn(11)
p.TakeDamage(dmg)
} else {
// Blessing: heal 15~25
heal := 15 + rand.Intn(11)
p.Heal(heal)
}
}
}
- Step 5: Run lobby tests
go test ./game/ -run TestCreate -v && go test ./game/ -run TestJoin -v
Expected: PASS.
- Step 6: Run session/turn tests
go test ./game/ -run TestSession -v -timeout 10s
Expected: PASS (turn resolves within 5s + buffer).
- Step 7: Commit
git add game/
git commit -m "feat: game session, turn system, lobby, and room events"
Task 6: BoltDB Store (Rankings & Player Identity)
Files:
-
Create:
store/db.go -
Test:
store/db_test.go -
Step 1: Write store test
store/db_test.go:
package store
import (
"os"
"testing"
)
func TestPlayerProfile(t *testing.T) {
db, err := Open("test.db")
if err != nil {
t.Fatal(err)
}
defer func() {
db.Close()
os.Remove("test.db")
}()
err = db.SaveProfile("SHA256:abc123", "TestPlayer")
if err != nil {
t.Fatal(err)
}
name, err := db.GetProfile("SHA256:abc123")
if err != nil {
t.Fatal(err)
}
if name != "TestPlayer" {
t.Errorf("Name: got %q, want %q", name, "TestPlayer")
}
}
func TestRanking(t *testing.T) {
db, err := Open("test_rank.db")
if err != nil {
t.Fatal(err)
}
defer func() {
db.Close()
os.Remove("test_rank.db")
}()
db.SaveRun("Alice", 20, 1500)
db.SaveRun("Bob", 15, 1000)
db.SaveRun("Charlie", 20, 2000)
rankings, err := db.TopRuns(10)
if err != nil {
t.Fatal(err)
}
if len(rankings) != 3 {
t.Errorf("Rankings: got %d, want 3", len(rankings))
}
if rankings[0].Player != "Charlie" {
t.Errorf("Top player: got %q, want Charlie", rankings[0].Player)
}
}
- Step 2: Run test to verify it fails
go test ./store/ -v
Expected: FAIL.
- Step 3: Implement store
store/db.go:
package store
import (
"encoding/json"
"fmt"
"sort"
bolt "go.etcd.io/bbolt"
)
var (
bucketProfiles = []byte("profiles")
bucketRankings = []byte("rankings")
)
type DB struct {
db *bolt.DB
}
type RunRecord struct {
Player string `json:"player"`
Floor int `json:"floor"`
Score int `json:"score"`
}
func Open(path string) (*DB, error) {
db, err := bolt.Open(path, 0600, nil)
if err != nil {
return nil, err
}
err = db.Update(func(tx *bolt.Tx) error {
if _, err := tx.CreateBucketIfNotExists(bucketProfiles); err != nil {
return err
}
if _, err := tx.CreateBucketIfNotExists(bucketRankings); err != nil {
return err
}
return nil
})
return &DB{db: db}, err
}
func (d *DB) Close() error {
return d.db.Close()
}
func (d *DB) SaveProfile(fingerprint, name string) error {
return d.db.Update(func(tx *bolt.Tx) error {
return tx.Bucket(bucketProfiles).Put([]byte(fingerprint), []byte(name))
})
}
func (d *DB) GetProfile(fingerprint string) (string, error) {
var name string
err := d.db.View(func(tx *bolt.Tx) error {
v := tx.Bucket(bucketProfiles).Get([]byte(fingerprint))
if v == nil {
return fmt.Errorf("profile not found")
}
name = string(v)
return nil
})
return name, err
}
func (d *DB) SaveRun(player string, floor, score int) error {
return d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketRankings)
id, _ := b.NextSequence()
record := RunRecord{Player: player, Floor: floor, Score: score}
data, err := json.Marshal(record)
if err != nil {
return err
}
return b.Put([]byte(fmt.Sprintf("%010d", id)), data)
})
}
func (d *DB) TopRuns(limit int) ([]RunRecord, error) {
var runs []RunRecord
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketRankings)
return b.ForEach(func(k, v []byte) error {
var r RunRecord
if err := json.Unmarshal(v, &r); err != nil {
return err
}
runs = append(runs, r)
return nil
})
})
if err != nil {
return nil, err
}
sort.Slice(runs, func(i, j int) bool {
if runs[i].Floor != runs[j].Floor {
return runs[i].Floor > runs[j].Floor
}
return runs[i].Score > runs[j].Score
})
if len(runs) > limit {
runs = runs[:limit]
}
return runs, nil
}
- Step 4: Run store tests
go test ./store/ -v
Expected: all PASS.
- Step 5: Commit
git add store/
git commit -m "feat: BoltDB store — player profiles and run rankings"
Task 7: TUI Views (Title, Lobby, Game, Result)
Files:
-
Modify:
ui/model.go -
Create:
ui/title.go,ui/lobby_view.go,ui/game_view.go,ui/result_view.go -
Step 1: Write title screen
ui/title.go:
package ui
import (
"github.com/charmbracelet/lipgloss"
)
var titleArt = `
██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ███╗ ███╗██████╗ ███████╗
██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗████╗ ████║██╔══██╗██╔════╝
██║ ███████║ ██║ ███████║██║ ██║ ██║██╔████╔██║██████╔╝███████╗
██║ ██╔══██║ ██║ ██╔══██║██║ ██║ ██║██║╚██╔╝██║██╔══██╗╚════██║
╚██████╗██║ ██║ ██║ ██║ ██║╚██████╗╚██████╔╝██║ ╚═╝ ██║██████╔╝███████║
╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝
`
func renderTitle(width, height int) string {
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Bold(true).
Align(lipgloss.Center)
subtitleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Align(lipgloss.Center)
menuStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Align(lipgloss.Center)
return lipgloss.JoinVertical(lipgloss.Center,
titleStyle.Render(titleArt),
"",
subtitleStyle.Render("A Co-op Roguelike Adventure"),
"",
menuStyle.Render("[Enter] Start [Q] Quit"),
)
}
- Step 2: Write lobby view
ui/lobby_view.go:
package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
type lobbyState struct {
rooms []roomInfo
input string
cursor int
creating bool
roomName string
}
type roomInfo struct {
Code string
Name string
Players int
Status string
}
func renderLobby(state lobbyState, width, height int) string {
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Bold(true)
roomStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Padding(0, 1)
header := headerStyle.Render("── Lobby ──")
menu := "[C] Create Room [J] Join by Code [Q] Back"
roomList := ""
for i, r := range state.rooms {
marker := " "
if i == state.cursor {
marker = "> "
}
roomList += fmt.Sprintf("%s%s [%s] (%d/4) %s\n",
marker, r.Name, r.Code, r.Players, r.Status)
}
if roomList == "" {
roomList = " No rooms available. Create one!"
}
return lipgloss.JoinVertical(lipgloss.Left,
header,
"",
roomStyle.Render(roomList),
"",
menu,
)
}
- Step 3: Write game view
ui/game_view.go:
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
"github.com/tolelom/catacombs/game"
)
func renderGame(state game.GameState, width, height int) string {
mapView := renderMap(state.Floor)
hudView := renderHUD(state)
return lipgloss.JoinVertical(lipgloss.Left,
mapView,
hudView,
)
}
func renderMap(floor *dungeon.Floor) string {
if floor == nil {
return ""
}
var sb strings.Builder
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
sb.WriteString(headerStyle.Render(fmt.Sprintf("── Catacombs B%d ──", floor.Number)))
sb.WriteString("\n\n")
roomStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder())
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
hiddenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
for i, room := range floor.Rooms {
vis := dungeon.GetRoomVisibility(floor, i)
symbol := roomTypeSymbol(room.Type)
label := fmt.Sprintf("[%d] %s %s", i, symbol, room.Type.String())
if i == floor.CurrentRoom {
label = ">> " + label + " <<"
}
switch vis {
case dungeon.Visible:
sb.WriteString(roomStyle.Render(label))
case dungeon.Visited:
sb.WriteString(dimStyle.Render(label))
case dungeon.Hidden:
sb.WriteString(hiddenStyle.Render("[?] ???"))
}
// Show connections
for _, n := range room.Neighbors {
if n > i {
sb.WriteString(" ─── ")
}
}
sb.WriteString("\n")
}
return sb.String()
}
func renderHUD(state game.GameState) string {
var sb strings.Builder
border := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
Padding(0, 1)
for _, p := range state.Players {
hpBar := renderHPBar(p.HP, p.MaxHP, 20)
status := ""
if p.IsDead() {
status = " [DEAD]"
}
sb.WriteString(fmt.Sprintf("%s (%s) %s %d/%d%s Gold: %d\n",
p.Name, p.Class, hpBar, p.HP, p.MaxHP, status, p.Gold))
}
if state.Phase == game.PhaseCombat {
sb.WriteString("\n")
for i, m := range state.Monsters {
if !m.IsDead() {
mhpBar := renderHPBar(m.HP, m.MaxHP, 15)
sb.WriteString(fmt.Sprintf(" [%d] %s %s %d/%d\n", i, m.Name, mhpBar, m.HP, m.MaxHP))
}
}
sb.WriteString("\n[1]Attack [2]Skill [3]Item [4]Flee [5]Wait")
} else if state.Phase == game.PhaseExploring {
sb.WriteString("\nChoose a room to enter (number) or [Q] quit")
}
return border.Render(sb.String())
}
func renderHPBar(current, max, width int) string {
if max == 0 {
return ""
}
filled := current * width / max
if filled < 0 {
filled = 0
}
empty := width - filled
greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46"))
redStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
bar := greenStyle.Render(strings.Repeat("█", filled)) +
redStyle.Render(strings.Repeat("░", empty))
return bar
}
func roomTypeSymbol(rt dungeon.RoomType) string {
switch rt {
case dungeon.RoomCombat:
return "D"
case dungeon.RoomTreasure:
return "$"
case dungeon.RoomShop:
return "S"
case dungeon.RoomEvent:
return "?"
case dungeon.RoomEmpty:
return "."
case dungeon.RoomBoss:
return "B"
default:
return " "
}
}
- Step 4: Write result view
ui/result_view.go:
package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/store"
)
func renderResult(won bool, floorReached int, rankings []store.RunRecord) string {
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
var title string
if won {
title = titleStyle.Render("VICTORY! You escaped the Catacombs!")
} else {
title = titleStyle.Render("GAME OVER")
}
floorInfo := fmt.Sprintf("Floor Reached: B%d", floorReached)
rankHeader := lipgloss.NewStyle().Bold(true).Render("── Rankings ──")
rankList := ""
for i, r := range rankings {
rankList += fmt.Sprintf(" %d. %s — B%d (Score: %d)\n", i+1, r.Player, r.Floor, r.Score)
}
menu := "[Enter] Return to Lobby [Q] Quit"
return lipgloss.JoinVertical(lipgloss.Center,
title,
"",
floorInfo,
"",
rankHeader,
rankList,
"",
menu,
)
}
- Step 5: Write class selection screen
ui/class_view.go:
package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/entity"
)
type classSelectState struct {
cursor int
}
var classOptions = []struct {
class entity.Class
name string
desc string
}{
{entity.ClassWarrior, "Warrior", "HP:120 ATK:12 DEF:8 Skill: Taunt (draw enemy fire)"},
{entity.ClassMage, "Mage", "HP:70 ATK:20 DEF:3 Skill: Fireball (AoE damage)"},
{entity.ClassHealer, "Healer", "HP:90 ATK:8 DEF:5 Skill: Heal (restore 30 HP)"},
{entity.ClassRogue, "Rogue", "HP:85 ATK:15 DEF:4 Skill: Scout (reveal rooms)"},
}
func renderClassSelect(state classSelectState, width, height int) string {
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Bold(true)
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("46")).
Bold(true)
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))
descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240"))
header := headerStyle.Render("── Choose Your Class ──")
list := ""
for i, opt := range classOptions {
marker := " "
style := normalStyle
if i == state.cursor {
marker = "> "
style = selectedStyle
}
list += fmt.Sprintf("%s%s\n %s\n\n",
marker, style.Render(opt.name), descStyle.Render(opt.desc))
}
menu := "[Up/Down] Select [Enter] Confirm"
return lipgloss.JoinVertical(lipgloss.Left,
header,
"",
list,
menu,
)
}
- Step 6: Update model.go with full state machine
ui/model.go — complete rewrite with all screens and input routing:
package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/tolelom/catacombs/entity"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
)
type screen int
const (
screenTitle screen = iota
screenLobby
screenClassSelect
screenGame
screenShop
screenResult
)
// StateUpdateMsg is sent by GameSession to update the view
type StateUpdateMsg struct {
State game.GameState
}
type Model struct {
width int
height int
fingerprint string
playerName string
screen screen
// Shared references (set by server)
lobby *game.Lobby
store *store.DB
// Per-session state
session *game.GameSession
roomCode string
gameState game.GameState
lobbyState lobbyState
classState classSelectState
inputBuffer string
}
func NewModel(width, height int, fingerprint string, lobby *game.Lobby, db *store.DB) Model {
return Model{
width: width,
height: height,
fingerprint: fingerprint,
screen: screenTitle,
lobby: lobby,
store: db,
}
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case StateUpdateMsg:
m.gameState = msg.State
return m, nil
}
switch m.screen {
case screenTitle:
return m.updateTitle(msg)
case screenLobby:
return m.updateLobby(msg)
case screenClassSelect:
return m.updateClassSelect(msg)
case screenGame:
return m.updateGame(msg)
case screenShop:
return m.updateShop(msg)
case screenResult:
return m.updateResult(msg)
}
return m, nil
}
func (m Model) View() string {
if m.width < 80 || m.height < 24 {
return fmt.Sprintf("Terminal too small (%dx%d). Minimum: 80x24.", m.width, m.height)
}
switch m.screen {
case screenTitle:
return renderTitle(m.width, m.height)
case screenLobby:
return renderLobby(m.lobbyState, m.width, m.height)
case screenClassSelect:
return renderClassSelect(m.classState, m.width, m.height)
case screenGame:
return renderGame(m.gameState, m.width, m.height)
case screenShop:
return renderShop(m.gameState, m.width, m.height)
case screenResult:
rankings, _ := m.store.TopRuns(10)
return renderResult(m.gameState.Victory, m.gameState.FloorNum, rankings)
}
return ""
}
func (m Model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "enter":
// Check if player has a name, if not prompt for one
name, err := m.store.GetProfile(m.fingerprint)
if err != nil {
m.playerName = "Adventurer"
} else {
m.playerName = name
}
m.screen = screenLobby
m.refreshLobbyState()
case "q", "ctrl+c":
return m, tea.Quit
}
}
return m, nil
}
func (m Model) updateLobby(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "c":
code := m.lobby.CreateRoom(m.playerName + "'s Room")
m.lobby.JoinRoom(code, m.playerName)
m.roomCode = code
m.screen = screenClassSelect
case "j":
// Join by code — simplified: use input buffer
case "up":
if m.lobbyState.cursor > 0 {
m.lobbyState.cursor--
}
case "down":
if m.lobbyState.cursor < len(m.lobbyState.rooms)-1 {
m.lobbyState.cursor++
}
case "enter":
if len(m.lobbyState.rooms) > 0 {
r := m.lobbyState.rooms[m.lobbyState.cursor]
if err := m.lobby.JoinRoom(r.Code, m.playerName); err == nil {
m.roomCode = r.Code
m.screen = screenClassSelect
}
}
case "q":
m.screen = screenTitle
}
}
return m, nil
}
func (m Model) updateClassSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "up":
if m.classState.cursor > 0 {
m.classState.cursor--
}
case "down":
if m.classState.cursor < len(classOptions)-1 {
m.classState.cursor++
}
case "enter":
selectedClass := classOptions[m.classState.cursor].class
room := m.lobby.GetRoom(m.roomCode)
if room.Session == nil {
room.Session = game.NewGameSession()
}
m.session = room.Session
player := entity.NewPlayer(m.playerName, selectedClass)
player.Fingerprint = m.fingerprint
m.session.AddPlayer(player)
m.session.StartGame()
m.gameState = m.session.GetState()
m.screen = screenGame
return m, m.listenForUpdates()
}
}
return m, nil
}
func (m Model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.gameState.GameOver {
m.screen = screenResult
return m, nil
}
if m.gameState.Phase == game.PhaseShop {
m.screen = screenShop
return m, nil
}
if key, ok := msg.(tea.KeyMsg); ok {
switch m.gameState.Phase {
case game.PhaseExploring:
// Number keys to select room
if key.String() >= "0" && key.String() <= "9" {
idx := int(key.String()[0] - '0')
m.session.EnterRoom(idx)
m.gameState = m.session.GetState()
}
case game.PhaseCombat:
switch key.String() {
case "1":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionAttack, TargetIdx: 0})
case "2":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionSkill, TargetIdx: 0})
case "3":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionItem})
case "4":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionFlee})
case "5":
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionWait})
}
}
}
return m, nil
}
func (m Model) updateShop(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "1", "2", "3":
idx := int(key.String()[0] - '1')
m.session.BuyItem(m.playerName, idx)
m.gameState = m.session.GetState()
case "q":
m.session.LeaveShop()
m.gameState = m.session.GetState()
m.screen = screenGame
}
}
return m, nil
}
func (m Model) updateResult(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "enter":
m.screen = screenLobby
m.refreshLobbyState()
case "q", "ctrl+c":
return m, tea.Quit
}
}
return m, nil
}
func (m *Model) refreshLobbyState() {
rooms := m.lobby.ListRooms()
m.lobbyState.rooms = make([]roomInfo, len(rooms))
for i, r := range rooms {
status := "Waiting"
if r.Status == game.RoomPlaying {
status = "Playing"
}
m.lobbyState.rooms[i] = roomInfo{
Code: r.Code,
Name: r.Name,
Players: len(r.Players),
Status: status,
}
}
m.lobbyState.cursor = 0
}
// listenForUpdates returns a tea.Cmd that waits for state updates from GameSession
func (m Model) listenForUpdates() tea.Cmd {
// This will be connected to GameSession's broadcast channel in Task 8
return nil
}
- Step 7: Write shop view
ui/shop_view.go:
package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/tolelom/catacombs/game"
)
func renderShop(state game.GameState, width, height int) string {
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
header := headerStyle.Render("── Shop ──")
items := ""
for i, item := range state.ShopItems {
items += fmt.Sprintf(" [%d] %s (+%d) — %d gold\n", i+1, item.Name, item.Bonus, item.Price)
}
menu := "[1-3] Buy [Q] Leave Shop"
return lipgloss.JoinVertical(lipgloss.Left,
header,
"",
items,
"",
menu,
)
}
- Step 8: Build and verify compilation
go build ./...
Expected: compiles without errors.
- Step 7: Commit
git add ui/
git commit -m "feat: TUI views — title, lobby, game map, HUD, result screen"
Task 8: Integration — Wire Everything Together
Files:
-
Modify:
main.go,server/ssh.go -
Step 1: Update main.go with store and lobby initialization
package main
import (
"log"
"os"
"os/signal"
"syscall"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/server"
"github.com/tolelom/catacombs/store"
)
func main() {
db, err := store.Open("data/catacombs.db")
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
lobby := game.NewLobby()
go func() {
if err := server.Start("0.0.0.0", 2222, lobby, db); err != nil {
log.Fatal(err)
}
}()
log.Println("Catacombs server running on :2222")
// Wait for interrupt
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
<-c
log.Println("Shutting down...")
}
- Step 2: Update server/ssh.go to pass lobby and store
package server
import (
"context"
"fmt"
"log"
"time"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/bubbletea"
"github.com/tolelom/catacombs/game"
"github.com/tolelom/catacombs/store"
"github.com/tolelom/catacombs/ui"
)
func Start(host string, port int, lobby *game.Lobby, db *store.DB) error {
s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf("%s:%d", host, port)),
wish.WithHostKeyPath(".ssh/catacombs_host_key"),
wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool {
return true
}),
wish.WithMiddleware(
bubbletea.Middleware(func(s ssh.Session) (bubbletea.Model, []bubbletea.ProgramOption) {
pty, _, _ := s.Pty()
fingerprint := ""
if s.PublicKey() != nil {
fingerprint = ssh.FingerprintSHA256(s.PublicKey())
}
m := ui.NewModel(pty.Window.Width, pty.Window.Height, fingerprint, lobby, db)
return m, []bubbletea.ProgramOption{bubbletea.WithAltScreen()}
}),
),
)
if err != nil {
return fmt.Errorf("could not create server: %w", err)
}
log.Printf("Starting SSH server on %s:%d", host, port)
return s.ListenAndServe()
}
- Step 3: Create data directory and verify build
mkdir -p E:/projects/catacombs/data
echo "data/" >> E:/projects/catacombs/.gitignore
go build ./...
Expected: compiles without errors.
- Step 4: Manual test — single player flow
go build -o catacombs . && ./catacombs &
ssh -p 2222 -o StrictHostKeyChecking=no localhost
Verify: title screen → lobby → create room → select class → enter dungeon → encounter combat → complete a few turns.
- Step 5: Manual test — two player co-op
Open two terminals, both SSH to localhost:2222. One creates a room, the other joins by code. Start game and verify both see the same dungeon state.
- Step 6: Commit
git add main.go server/ .gitignore
git commit -m "feat: wire up SSH server with lobby, store, and graceful shutdown"
Task 9: Docker & Deployment
Files:
-
Modify:
Dockerfile,docker-compose.yml -
Step 1: Test Docker build
docker build -t catacombs .
Expected: image builds successfully.
- Step 2: Test Docker run
docker run -p 2222:2222 --name catacombs-test catacombs &
ssh -p 2222 -o StrictHostKeyChecking=no localhost
Expected: game loads in terminal.
- Step 3: Stop and clean up test container
docker stop catacombs-test && docker rm catacombs-test
- Step 4: Commit
git add Dockerfile docker-compose.yml
git commit -m "feat: Docker build and compose for deployment"
Task 10: Polish & Final Testing
- Step 1: Terminal size check
Add terminal size validation on connect. If width < 80 or height < 24, display a warning message instead of the game.
- Step 2: Chat system
Add / key handler in combat view to open a one-line chat input. Messages broadcast to all party members via GameSession.
- Step 3: Run all tests
go test ./... -v
Expected: all PASS.
- Step 4: Full playthrough test
Start server, connect 2 players via SSH, play through at least 5 floors of co-op. Verify:
-
Class selection works
-
Combat resolves correctly
-
Turns time out properly
-
Dead player sees spectator view
-
Disconnect/reconnect works
-
Gold is distributed correctly
-
Step 5: Final commit
git add -A
git commit -m "feat: polish — terminal size check, chat system, final testing"