9 tasks: daily challenge seed/store, unlock system, player titles, codex system+UI, hard mode/mutations, integration, verification. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
21 KiB
Phase 3: Retention Systems 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: Add retention mechanics (daily challenges, meta progression, difficulty modes) that give players reasons to keep coming back.
Architecture: Extend the BoltDB store with 4 new buckets (daily_runs, unlocks, titles, codex). Daily challenges use the Phase 2 seed-based generator with a date-derived seed. Unlock/title/codex systems follow the existing achievements pattern (key-value pairs per player). Hard mode and weekly mutations modify game config at session creation. New UI screens (codex_view) follow the Phase 1 Screen interface pattern.
Tech Stack: Go 1.25.1, BoltDB, math/rand, crypto/sha256 for date→seed hashing
Module path: github.com/tolelom/catacombs
File Structure
New Files
| File | Responsibility |
|---|---|
store/daily.go |
DailyRecord struct; SaveDaily, GetDailyLeaderboard, GetStreak methods |
store/daily_test.go |
Daily record persistence tests |
store/unlocks.go |
Unlock definitions; CheckUnlock, GetUnlocks methods |
store/unlocks_test.go |
Unlock condition tests |
store/titles.go |
Title definitions; EarnTitle, SetActiveTitle, GetTitles methods |
store/titles_test.go |
Title persistence tests |
store/codex.go |
Codex struct; RecordMonster/Item/Event, GetCodex methods |
store/codex_test.go |
Codex tracking tests |
game/daily.go |
DailySeed generation; CreateDailySession helper |
game/daily_test.go |
Seed determinism tests |
game/mutation.go |
Mutation definitions; GetWeeklyMutation, ApplyMutation |
game/mutation_test.go |
Mutation application tests |
ui/codex_view.go |
CodexScreen implementing Screen interface |
Modified Files
| File | Changes |
|---|---|
store/db.go |
Create 4 new buckets in Open(); use cfg.Game.MaxFloors in GetStats victory check |
config/config.go |
Add DifficultyConfig (HardMode multipliers) |
config.yaml |
Add difficulty section |
game/session.go |
Add DailyMode/HardMode/Mutation fields to GameSession; seed-based generation for daily; codex recording hooks |
game/event.go |
Record codex entries on monster spawn, treasure, events |
game/turn.go |
Record codex on monster kills; apply hard mode multipliers |
ui/lobby_view.go |
Add daily challenge button; show hard mode option if unlocked |
ui/game_view.go |
Show daily/hard mode indicators in HUD; save daily record on game over |
ui/title.go |
Show active title next to player name on title screen |
ui/leaderboard_view.go |
Add daily leaderboard tab |
Task 1: Daily Challenge Seed System
Files:
-
Create:
game/daily.go -
Create:
game/daily_test.go -
Step 1: Write determinism test
// game/daily_test.go
package game
import (
"math/rand"
"testing"
"github.com/tolelom/catacombs/dungeon"
)
func TestDailySeed(t *testing.T) {
seed1 := DailySeed("2026-03-25")
seed2 := DailySeed("2026-03-25")
if seed1 != seed2 {
t.Errorf("same date should produce same seed: %d vs %d", seed1, seed2)
}
seed3 := DailySeed("2026-03-26")
if seed1 == seed3 {
t.Error("different dates should produce different seeds")
}
}
func TestDailyFloorDeterminism(t *testing.T) {
seed := DailySeed("2026-03-25")
rng1 := rand.New(rand.NewSource(seed))
rng2 := rand.New(rand.NewSource(seed))
f1 := dungeon.GenerateFloor(1, rng1)
f2 := dungeon.GenerateFloor(1, rng2)
if len(f1.Rooms) != len(f2.Rooms) {
t.Fatal("daily floors should be identical")
}
for i := range f1.Rooms {
if f1.Rooms[i].Type != f2.Rooms[i].Type {
t.Errorf("room %d type differs", i)
}
}
}
- Step 2: Implement DailySeed
// game/daily.go
package game
import (
"crypto/sha256"
"encoding/binary"
)
// DailySeed returns a deterministic seed for the given date string (YYYY-MM-DD).
func DailySeed(date string) int64 {
h := sha256.Sum256([]byte("catacombs:" + date))
return int64(binary.BigEndian.Uint64(h[:8]))
}
- Step 3: Run tests
Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./game/ -run TestDaily -v
Expected: PASS
- Step 4: Commit
git add game/daily.go game/daily_test.go
git commit -m "feat: add daily challenge seed generation"
Task 2: Daily Record Store
Files:
-
Create:
store/daily.go -
Create:
store/daily_test.go -
Modify:
store/db.go(add bucket creation) -
Step 1: Write tests
// store/daily_test.go
package store
import (
"os"
"testing"
)
func TestSaveAndGetDaily(t *testing.T) {
f, _ := os.CreateTemp("", "daily-*.db")
f.Close()
defer os.Remove(f.Name())
db, err := Open(f.Name())
if err != nil {
t.Fatal(err)
}
defer db.Close()
err = db.SaveDaily(DailyRecord{
Date: "2026-03-25", Player: "Alice", PlayerName: "Alice",
FloorReached: 10, GoldEarned: 200,
})
if err != nil {
t.Fatal(err)
}
records, err := db.GetDailyLeaderboard("2026-03-25", 10)
if err != nil {
t.Fatal(err)
}
if len(records) != 1 {
t.Fatalf("expected 1 record, got %d", len(records))
}
if records[0].FloorReached != 10 {
t.Errorf("expected floor 10, got %d", records[0].FloorReached)
}
}
func TestDailyStreak(t *testing.T) {
f, _ := os.CreateTemp("", "streak-*.db")
f.Close()
defer os.Remove(f.Name())
db, _ := Open(f.Name())
defer db.Close()
db.SaveDaily(DailyRecord{Date: "2026-03-23", Player: "fp1", PlayerName: "A", FloorReached: 5, GoldEarned: 50})
db.SaveDaily(DailyRecord{Date: "2026-03-24", Player: "fp1", PlayerName: "A", FloorReached: 8, GoldEarned: 100})
db.SaveDaily(DailyRecord{Date: "2026-03-25", Player: "fp1", PlayerName: "A", FloorReached: 12, GoldEarned: 150})
streak := db.GetStreak("fp1", "2026-03-25")
if streak != 3 {
t.Errorf("expected streak 3, got %d", streak)
}
}
- Step 2: Implement daily store
// store/daily.go
package store
import (
"encoding/json"
"fmt"
"sort"
"time"
bolt "go.etcd.io/bbolt"
)
var bucketDailyRuns = []byte("daily_runs")
type DailyRecord struct {
Date string `json:"date"`
Player string `json:"player"` // fingerprint
PlayerName string `json:"player_name"`
FloorReached int `json:"floor_reached"`
GoldEarned int `json:"gold_earned"`
}
func (d *DB) SaveDaily(record DailyRecord) error {
return d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketDailyRuns)
key := []byte(record.Date + ":" + record.Player)
data, err := json.Marshal(record)
if err != nil {
return err
}
return b.Put(key, data)
})
}
func (d *DB) GetDailyLeaderboard(date string, limit int) ([]DailyRecord, error) {
var records []DailyRecord
prefix := []byte(date + ":")
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketDailyRuns)
c := b.Cursor()
for k, v := c.Seek(prefix); k != nil && len(k) >= len(prefix) && string(k[:len(prefix)]) == string(prefix); k, v = c.Next() {
var r DailyRecord
if json.Unmarshal(v, &r) == nil {
records = append(records, r)
}
}
return nil
})
if err != nil {
return nil, err
}
sort.Slice(records, func(i, j int) bool {
if records[i].FloorReached != records[j].FloorReached {
return records[i].FloorReached > records[j].FloorReached
}
return records[i].GoldEarned > records[j].GoldEarned
})
if len(records) > limit {
records = records[:limit]
}
return records, nil
}
func (d *DB) GetStreak(fingerprint, currentDate string) int {
streak := 0
date, err := time.Parse("2006-01-02", currentDate)
if err != nil {
return 0
}
for i := 0; i < 365; i++ {
checkDate := date.AddDate(0, 0, -i).Format("2006-01-02")
key := []byte(checkDate + ":" + fingerprint)
found := false
d.db.View(func(tx *bolt.Tx) error {
if tx.Bucket(bucketDailyRuns).Get(key) != nil {
found = true
}
return nil
})
if found {
streak++
} else {
break
}
}
return streak
}
In store/db.go Open(), add bucket creation:
if _, err := tx.CreateBucketIfNotExists(bucketDailyRuns); err != nil {
return err
}
- Step 3: Run tests
Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./store/ -v
Expected: ALL PASS
- Step 4: Commit
git add store/daily.go store/daily_test.go store/db.go
git commit -m "feat: add daily challenge record storage and leaderboard"
Task 3: Unlock System
Files:
-
Create:
store/unlocks.go -
Create:
store/unlocks_test.go -
Modify:
store/db.go(add bucket) -
Step 1: Implement unlock system
Follow the achievements.go pattern. Define 3 unlocks:
"fifth_class": "Clear floor 10+" → unlocks 5th class (future)"hard_mode": "Clear with 3+ players" → unlocks hard mode"mutations": "Clear floor 20" → unlocks weekly mutations
// store/unlocks.go
package store
import bolt "go.etcd.io/bbolt"
var bucketUnlocks = []byte("unlocks")
type UnlockDef struct {
ID string
Name string
Description string
}
var UnlockDefs = []UnlockDef{
{ID: "fifth_class", Name: "New Recruit", Description: "Clear floor 10 to unlock a new class"},
{ID: "hard_mode", Name: "Hardened", Description: "Complete a run with 3+ players to unlock Hard Mode"},
{ID: "mutations", Name: "Chaos Unleashed", Description: "Conquer floor 20 to unlock Weekly Mutations"},
}
func (d *DB) UnlockContent(fingerprint, unlockID string) (bool, error) {
key := []byte(fingerprint + ":" + unlockID)
alreadyUnlocked := false
err := d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketUnlocks)
if b.Get(key) != nil {
alreadyUnlocked = true
return nil
}
return b.Put(key, []byte("1"))
})
return !alreadyUnlocked, err
}
func (d *DB) IsUnlocked(fingerprint, unlockID string) bool {
key := []byte(fingerprint + ":" + unlockID)
found := false
d.db.View(func(tx *bolt.Tx) error {
if tx.Bucket(bucketUnlocks).Get(key) != nil {
found = true
}
return nil
})
return found
}
func (d *DB) GetUnlocks(fingerprint string) []UnlockDef {
result := make([]UnlockDef, len(UnlockDefs))
copy(result, UnlockDefs)
// Mark unlocked ones (reuse the struct, no Unlocked field — caller checks IsUnlocked)
return result
}
Add bucketUnlocks creation in store/db.go Open().
- Step 2: Write tests and verify
Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./store/ -v
- Step 3: Commit
git add store/unlocks.go store/unlocks_test.go store/db.go
git commit -m "feat: add unlock system with 3 unlockable contents"
Task 4: Player Titles
Files:
-
Create:
store/titles.go -
Create:
store/titles_test.go -
Modify:
store/db.go(add bucket) -
Step 1: Implement titles
// store/titles.go
package store
import (
"encoding/json"
bolt "go.etcd.io/bbolt"
)
var bucketTitles = []byte("titles")
type TitleDef struct {
ID string
Name string
}
var TitleDefs = []TitleDef{
{ID: "novice", Name: "Novice"},
{ID: "explorer", Name: "Explorer"}, // reach floor 5
{ID: "veteran", Name: "Veteran"}, // reach floor 10
{ID: "champion", Name: "Champion"}, // reach floor 20
{ID: "gold_king", Name: "Gold King"}, // earn 500+ gold in one run
{ID: "team_player", Name: "Team Player"}, // complete 5 multiplayer runs
{ID: "survivor", Name: "Survivor"}, // complete a run without dying
}
type PlayerTitleData struct {
ActiveTitle string `json:"active_title"`
Earned []string `json:"earned"`
}
func (d *DB) EarnTitle(fingerprint, titleID string) (bool, error) {
data := d.loadTitleData(fingerprint)
for _, t := range data.Earned {
if t == titleID {
return false, nil // already earned
}
}
data.Earned = append(data.Earned, titleID)
if data.ActiveTitle == "" {
data.ActiveTitle = titleID
}
return true, d.saveTitleData(fingerprint, data)
}
func (d *DB) SetActiveTitle(fingerprint, titleID string) error {
data := d.loadTitleData(fingerprint)
data.ActiveTitle = titleID
return d.saveTitleData(fingerprint, data)
}
func (d *DB) GetTitleData(fingerprint string) PlayerTitleData {
return d.loadTitleData(fingerprint)
}
func (d *DB) loadTitleData(fingerprint string) PlayerTitleData {
var data PlayerTitleData
d.db.View(func(tx *bolt.Tx) error {
v := tx.Bucket(bucketTitles).Get([]byte(fingerprint))
if v != nil {
json.Unmarshal(v, &data)
}
return nil
})
return data
}
func (d *DB) saveTitleData(fingerprint string, data PlayerTitleData) error {
return d.db.Update(func(tx *bolt.Tx) error {
raw, _ := json.Marshal(data)
return tx.Bucket(bucketTitles).Put([]byte(fingerprint), raw)
})
}
Add bucketTitles to store/db.go Open().
- Step 2: Write tests and verify
- Step 3: Commit
git add store/titles.go store/titles_test.go store/db.go
git commit -m "feat: add player title system with 7 titles"
Task 5: Codex System
Files:
-
Create:
store/codex.go -
Create:
store/codex_test.go -
Modify:
store/db.go(add bucket) -
Modify:
game/event.go(record monster/item/event encounters) -
Step 1: Implement codex store
// store/codex.go
package store
import (
"encoding/json"
bolt "go.etcd.io/bbolt"
)
var bucketCodex = []byte("codex")
type Codex struct {
Monsters map[string]bool `json:"monsters"`
Items map[string]bool `json:"items"`
Events map[string]bool `json:"events"`
}
func (d *DB) RecordCodexEntry(fingerprint, category, id string) error {
return d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketCodex)
var codex Codex
if v := b.Get([]byte(fingerprint)); v != nil {
json.Unmarshal(v, &codex)
}
if codex.Monsters == nil {
codex.Monsters = make(map[string]bool)
}
if codex.Items == nil {
codex.Items = make(map[string]bool)
}
if codex.Events == nil {
codex.Events = make(map[string]bool)
}
switch category {
case "monster":
codex.Monsters[id] = true
case "item":
codex.Items[id] = true
case "event":
codex.Events[id] = true
}
raw, _ := json.Marshal(codex)
return b.Put([]byte(fingerprint), raw)
})
}
func (d *DB) GetCodex(fingerprint string) Codex {
var codex Codex
d.db.View(func(tx *bolt.Tx) error {
if v := tx.Bucket(bucketCodex).Get([]byte(fingerprint)); v != nil {
json.Unmarshal(v, &codex)
}
return nil
})
if codex.Monsters == nil {
codex.Monsters = make(map[string]bool)
}
if codex.Items == nil {
codex.Items = make(map[string]bool)
}
if codex.Events == nil {
codex.Events = make(map[string]bool)
}
return codex
}
Add bucketCodex to store/db.go. The codex recording hooks in game/event.go will be added — but since GameSession doesn't have a DB reference, recording will happen from the UI layer (GameScreen) which has access to ctx.Store. Add codex recording in ui/game_view.go when the gameState changes (new monsters, items, events).
- Step 2: Write tests and verify
- Step 3: Commit
git add store/codex.go store/codex_test.go store/db.go
git commit -m "feat: add codex system for monster/item/event tracking"
Task 6: Codex UI Screen
Files:
-
Create:
ui/codex_view.go -
Modify:
ui/lobby_view.goorui/title.go(add navigation to codex) -
Step 1: Create CodexScreen
// ui/codex_view.go
package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/tolelom/catacombs/store"
)
type CodexScreen struct {
codex store.Codex
tab int // 0=monsters, 1=items, 2=events
}
func NewCodexScreen(ctx *Context) *CodexScreen {
codex := ctx.Store.GetCodex(ctx.Fingerprint)
return &CodexScreen{codex: codex}
}
Implement Update (Tab to switch tabs, Esc to go back) and View (show discovered entries with completion percentage per category).
Add a keybinding in the title screen or lobby to open the codex (e.g., c key).
- Step 2: Run tests
- Step 3: Commit
git add ui/codex_view.go ui/title.go
git commit -m "feat: add codex UI screen with completion tracking"
Task 7: Hard Mode and Weekly Mutations
Files:
-
Create:
game/mutation.go -
Create:
game/mutation_test.go -
Modify:
config/config.go(add DifficultyConfig) -
Modify:
game/session.go(add HardMode/Mutation fields, apply at session start) -
Modify:
ui/lobby_view.go(show hard mode toggle if unlocked) -
Step 1: Add DifficultyConfig
// Add to config/config.go
type DifficultyConfig struct {
HardModeMonsterMult float64 `yaml:"hard_mode_monster_mult"` // default: 1.5
HardModeShopMult float64 `yaml:"hard_mode_shop_mult"` // default: 2.0
HardModeHealMult float64 `yaml:"hard_mode_heal_mult"` // default: 0.5
}
Add to Config struct and defaults.
- Step 2: Implement mutations
// game/mutation.go
package game
import (
"crypto/sha256"
"encoding/binary"
"time"
"github.com/tolelom/catacombs/config"
)
type Mutation struct {
ID string
Name string
Description string
Apply func(cfg *config.GameConfig)
}
var mutations = []Mutation{
{ID: "no_skills", Name: "Skill Lockout", Description: "Class skills are disabled",
Apply: func(cfg *config.GameConfig) { cfg.SkillUses = 0 }},
{ID: "elite_flood", Name: "Elite Flood", Description: "All monsters are elite",
Apply: func(cfg *config.GameConfig) {}}, // handled in event.go spawn logic
{ID: "no_shop", Name: "Shop Closed", Description: "Shops are unavailable",
Apply: func(cfg *config.GameConfig) {}}, // handled in event.go room generation
{ID: "glass_cannon", Name: "Glass Cannon", Description: "Double ATK, half HP",
Apply: func(cfg *config.GameConfig) {}}, // handled at player creation
{ID: "speed_run", Name: "Speed Run", Description: "Turn timeout halved",
Apply: func(cfg *config.GameConfig) { cfg.TurnTimeoutSec = cfg.TurnTimeoutSec / 2 }},
}
func GetWeeklyMutation() Mutation {
_, week := time.Now().ISOWeek()
year, _ := time.Now().ISOWeek()
h := sha256.Sum256([]byte(fmt.Sprintf("mutation:%d:%d", year, week)))
idx := int(binary.BigEndian.Uint64(h[:8])) % len(mutations)
return mutations[idx]
}
- Step 3: Wire into session
Add HardMode bool and ActiveMutation *Mutation fields to GameSession. When creating a session for hard mode, apply difficulty multipliers to a cloned config. When creating a mutation session, call mutation.Apply(&cfg.Game).
- Step 4: Add hard mode toggle in lobby UI
In LobbyScreen, if ctx.Store.IsUnlocked(ctx.Fingerprint, "hard_mode"), show a [H] Hard Mode toggle. Store the selection and pass to session creation.
- Step 5: Run all tests
Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./... -v
- Step 6: Commit
git add game/mutation.go game/mutation_test.go config/ game/session.go ui/lobby_view.go config.yaml
git commit -m "feat: add hard mode and weekly mutation system"
Task 8: Integration — Daily Challenge Flow + Codex Recording
Files:
-
Modify:
ui/lobby_view.go(add daily challenge button) -
Modify:
ui/game_view.go(save daily record on game over; record codex entries) -
Modify:
ui/leaderboard_view.go(add daily tab) -
Modify:
game/session.go(add DailyMode field, use daily seed) -
Step 1: Add DailyMode to GameSession
Add DailyMode bool and DailyDate string fields. When DailyMode, use DailySeed(date) to create the RNG for GenerateFloor.
- Step 2: Add daily challenge in lobby
In LobbyScreen, add [D] Daily Challenge button. Creates a solo session with DailyMode=true using today's date.
- Step 3: Save daily record on game over
In GameScreen, when game ends and ctx.Session.DailyMode, call ctx.Store.SaveDaily(...).
- Step 4: Record codex entries
In GameScreen.Update(), when gameState shows new monsters (entering combat), record them to codex. When getting items (treasure/shop), record items. When events trigger, record event IDs.
- Step 5: Add daily leaderboard tab
In LeaderboardScreen, add a tab for daily leaderboard using ctx.Store.GetDailyLeaderboard(today, 20).
- Step 6: Trigger unlock checks
In GameScreen, after game over:
- Floor >= 10 → unlock "fifth_class"
- Players >= 3 && floor >= 5 → unlock "hard_mode"
- Victory (floor >= maxFloors) → unlock "mutations"
Trigger title earning based on conditions.
- Step 7: Run all tests
Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./... -v
- Step 8: Commit
git add ui/ game/session.go
git commit -m "feat: integrate daily challenges, codex recording, and unlock triggers"
Task 9: Final Verification
- Step 1: Run full test suite
Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go test ./... -v
Expected: ALL PASS
- Step 2: Run go vet
Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go vet ./...
Expected: No issues
- Step 3: Build
Run: cd C:/Users/SSAFY/Desktop/projects/Catacombs && go build -o catacombs.exe .
Expected: Success
- Step 4: Verify new BoltDB buckets
Confirm store/db.go Open() creates all 7 buckets: profiles, rankings, achievements, daily_runs, unlocks, titles, codex.
- Step 5: Commit if cleanup needed
git add -A
git commit -m "chore: phase 3 complete — retention systems verified"