golfgame/V2_BUILD_PLAN.md
Aaron D. Lee bea85e6b28 Huge v2 uplift, now deployable with real user management and tooling!
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:32:15 -05:00

523 lines
17 KiB
Markdown

# Golf Card Game - V2 Build Plan
## Vision
Transform the current single-server Golf game into a production-ready, hostable platform with:
- **Event-sourced architecture** for full game replay and audit trails
- **Leaderboards** with player statistics
- **Scalable hosting** options (self-hosted or cloud)
- **Export/playback** for sharing memorable games
---
## Current State (V1)
```
Client (Vanilla JS) ◄──WebSocket──► FastAPI Server ◄──► SQLite
In-memory rooms
(lost on restart)
```
**What works well:**
- Game logic is solid and well-tested
- CPU AI with multiple personalities
- House rules system is flexible
- Real-time multiplayer via WebSockets
**Limitations:**
- Single server, no horizontal scaling
- Game state lost on server restart
- Move logging exists but duplicates state
- No player accounts with persistent stats
---
## V2 Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Clients │
│ (Browser / Future: Mobile) │
└───────────────────────────────┬─────────────────────────────────────┘
│ WebSocket + REST API
┌─────────────────────────────────────────────────────────────────────┐
│ FastAPI Application │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │
│ │ Command │ │ Event │ │ State │ │ Query │ │
│ │ Handler │──► Store │──► Builder │ │ Service │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │
└───────┬───────────────────┬───────────────────┬───────────────┬─────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌─────────────┐ ┌───────────┐
│ Redis │ │ PostgreSQL │ │ PostgreSQL │ │ Postgres │
│ (Live State) │ │ (Events) │ │ (Users) │ │ (Stats) │
│ (Pub/Sub) │ │ │ │ │ │ │
└──────────────┘ └──────────────┘ └─────────────┘ └───────────┘
```
---
## Data Model
### Event Store
All game actions stored as immutable events:
```sql
-- Core event log
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
game_id UUID NOT NULL,
sequence_num INT NOT NULL,
event_type VARCHAR(50) NOT NULL,
event_data JSONB NOT NULL,
player_id VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(game_id, sequence_num)
);
-- Game metadata (denormalized for queries)
CREATE TABLE games (
id UUID PRIMARY KEY,
room_code VARCHAR(10),
status VARCHAR(20) DEFAULT 'active', -- active, completed, abandoned
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
num_players INT,
num_rounds INT,
options JSONB,
winner_id VARCHAR(50),
-- Denormalized for leaderboard queries
player_ids VARCHAR(50)[]
);
CREATE INDEX idx_events_game ON events(game_id, sequence_num);
CREATE INDEX idx_games_status ON games(status, completed_at);
CREATE INDEX idx_games_players ON games USING GIN(player_ids);
```
### Event Types
```python
@dataclass
class GameEvent:
game_id: str
sequence_num: int
event_type: str
player_id: Optional[str]
timestamp: datetime
data: dict
# Lifecycle events
GameCreated(room_code, options, host_id)
PlayerJoined(player_id, player_name, is_cpu, profile_name?)
PlayerLeft(player_id, reason)
GameStarted(deck_seed, player_order)
RoundStarted(round_num)
RoundEnded(scores: dict, winner_id)
GameEnded(final_scores: dict, winner_id)
# Gameplay events
InitialCardsFlipped(player_id, positions: list[int])
CardDrawn(player_id, source: "deck"|"discard", card: Card)
CardSwapped(player_id, position: int, new_card: Card, old_card: Card)
CardDiscarded(player_id, card: Card)
CardFlipped(player_id, position: int, card: Card)
FlipSkipped(player_id)
FlipAsAction(player_id, position: int, card: Card)
```
### User & Stats Schema
```sql
-- User accounts (expand existing auth)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE,
password_hash VARCHAR(255),
role VARCHAR(20) DEFAULT 'player',
created_at TIMESTAMPTZ DEFAULT NOW(),
last_seen_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT true,
preferences JSONB DEFAULT '{}'
);
-- Player statistics (materialized from events)
CREATE TABLE player_stats (
user_id UUID PRIMARY KEY REFERENCES users(id),
games_played INT DEFAULT 0,
games_won INT DEFAULT 0,
rounds_played INT DEFAULT 0,
rounds_won INT DEFAULT 0,
total_points INT DEFAULT 0, -- Lower is better
best_round_score INT,
worst_round_score INT,
total_knockouts INT DEFAULT 0, -- Times going out first
total_blunders INT DEFAULT 0, -- From AI analyzer
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Leaderboard views
CREATE VIEW leaderboard_by_wins AS
SELECT
u.username,
s.games_played,
s.games_won,
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
s.rounds_won,
ROUND(s.total_points::numeric / NULLIF(s.rounds_played, 0), 1) as avg_score
FROM player_stats s
JOIN users u ON s.user_id = u.id
WHERE s.games_played >= 25 -- Minimum games for ranking
ORDER BY win_rate DESC, games_won DESC;
CREATE VIEW leaderboard_by_games AS
SELECT
u.username,
s.games_played,
s.games_won,
s.rounds_won
FROM player_stats s
JOIN users u ON s.user_id = u.id
ORDER BY games_played DESC;
```
---
## Components to Build
### Phase 1: Event Infrastructure (Foundation)
| Component | Description | Effort |
|-----------|-------------|--------|
| Event classes | Python dataclasses for all event types | S |
| Event store | PostgreSQL table + write functions | S |
| State rebuilder | Fold events into GameState | M |
| Dual-write migration | Emit events alongside current mutations | M |
| Event validation | Ensure events can recreate identical state | M |
### Phase 2: Persistence & Recovery
| Component | Description | Effort |
|-----------|-------------|--------|
| Redis state cache | Store live game state in Redis | M |
| Pub/sub for multi-server | Redis pub/sub for WebSocket fan-out | M |
| Game recovery | Rebuild in-progress games from events on restart | S |
| Graceful shutdown | Save state before shutdown | S |
### Phase 3: User System & Stats
| Component | Description | Effort |
|-----------|-------------|--------|
| User registration flow | Proper signup/login UI | M |
| Guest-to-user conversion | Play as guest, register to save stats | S |
| Stats aggregation worker | Process events → update player_stats | M |
| Leaderboard API | REST endpoints for leaderboards | S |
| Leaderboard UI | Display in client | M |
### Phase 4: Replay & Export
| Component | Description | Effort |
|-----------|-------------|--------|
| Export API | `GET /api/games/{id}/export` returns event JSON | S |
| Import/load | Load exported game for replay | S |
| Replay UI | Playback controls, scrubbing, speed control | L |
| Share links | `/replay/{game_id}` public URLs | S |
### Phase 5: Production Hardening
| Component | Description | Effort |
|-----------|-------------|--------|
| Rate limiting | Prevent abuse | S |
| Health checks | `/health` with dependency checks | S |
| Metrics | Prometheus metrics for monitoring | M |
| Error tracking | Sentry or similar | S |
| Backup strategy | Automated PostgreSQL backups | S |
---
## Tech Stack
### Recommended Stack
| Layer | Technology | Reasoning |
|-------|------------|-----------|
| **Web framework** | FastAPI (keep) | Already using, async, fast |
| **WebSockets** | Starlette (keep) | Built into FastAPI |
| **Live state cache** | Redis | Fast, pub/sub, TTL, battle-tested |
| **Event store** | PostgreSQL | JSONB, robust, great tooling |
| **User database** | PostgreSQL | Same instance, keep it simple |
| **Background jobs** | `arq` or `rq` | Stats aggregation, cleanup |
| **Containerization** | Docker | Consistent deployment |
| **Orchestration** | Docker Compose (small) / K8s (large) | Start simple |
### Dependencies to Add
```txt
# requirements.txt additions
redis>=5.0.0
asyncpg>=0.29.0 # Async PostgreSQL
sqlalchemy>=2.0.0 # ORM (optional, can use raw SQL)
alembic>=1.13.0 # Migrations
arq>=0.26.0 # Background tasks
pydantic-settings>=2.0 # Config management
```
---
## Hosting Options
### Option A: Single VPS (Simplest, $5-20/mo)
```
┌─────────────────────────────────────┐
│ VPS (2-4GB RAM) │
│ ┌─────────┐ ┌─────────┐ ┌───────┐ │
│ │ FastAPI │ │ Redis │ │Postgres│ │
│ │ :8000 │ │ :6379 │ │ :5432 │ │
│ └─────────┘ └─────────┘ └───────┘ │
│ Docker Compose │
└─────────────────────────────────────┘
Providers: DigitalOcean, Linode, Hetzner, Vultr
Capacity: ~100-500 concurrent users
```
**docker-compose.yml:**
```yaml
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://golf:secret@db:5432/golf
- REDIS_URL=redis://redis:6379
depends_on:
- db
- redis
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: golf
POSTGRES_PASSWORD: secret
POSTGRES_DB: golf
volumes:
- postgres_data:/var/lib/postgresql/data
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./certs:/etc/nginx/certs
volumes:
redis_data:
postgres_data:
```
### Option B: Managed Services ($20-50/mo)
```
┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Fly.io │ │ Upstash Redis │ │ Neon or │
│ App │◄───►│ (Serverless) │ │ Supabase │
│ $5-10/mo │ │ Free-$10/mo │ │ PostgreSQL │
└──────────────┘ └─────────────────┘ │ Free-$25/mo │
└──────────────┘
Alternative compute: Railway, Render, Google Cloud Run
```
**Pros:** Less ops, automatic SSL, easy scaling
**Cons:** Slightly higher latency, vendor lock-in
### Option C: Self-Hosted (Home Server / NAS)
```
┌─────────────────────────────────────┐
│ Home Server / Raspberry Pi 5 │
│ Docker Compose (same as Option A) │
└───────────────────┬─────────────────┘
┌───────────────────▼─────────────────┐
│ Cloudflare Tunnel (free) │
│ • No port forwarding needed │
│ • Free SSL │
│ • DDoS protection │
└─────────────────────────────────────┘
Domain: golf.yourdomain.com
```
### Option D: Kubernetes (Overkill Unless Scaling Big)
Only if you're expecting 5000+ concurrent users or need multi-region.
---
## Migration Strategy
### Step 1: Add Event Emission (Non-Breaking)
Keep current code working, add event logging in parallel:
```python
# In game.py or main.py
def draw_card(self, player_id: str, source: str) -> Optional[Card]:
# Existing logic
card = self._do_draw(player_id, source)
if card:
# NEW: Emit event (doesn't affect gameplay)
self.emit_event(CardDrawn(
player_id=player_id,
source=source,
card=card.to_dict()
))
return card
```
### Step 2: Validate Event Replay
Build a test that:
1. Plays a game normally
2. Captures all events
3. Replays events into fresh state
4. Asserts final state matches
```python
def test_event_replay_matches():
# Play a game, collect events
game, events = play_test_game()
final_state = game.get_state()
# Rebuild from events
rebuilt = GameState()
for event in events:
rebuilt.apply(event)
assert rebuilt == final_state
```
### Step 3: Switch to Event-Sourced
Once validation passes:
1. Commands produce events
2. Events applied to state
3. State derived, not mutated directly
### Step 4: Deploy New Infrastructure
1. Set up PostgreSQL + Redis
2. Deploy with feature flag (old vs new storage)
3. Run both in parallel, compare
4. Cut over when confident
---
## Milestones & Timeline
| Phase | Milestone | Dependencies |
|-------|-----------|--------------|
| **1** | Events emitting alongside current code | None |
| **1** | Event replay test passing | Events emitting |
| **2** | Redis state cache working | None |
| **2** | Server survives restart (games recover) | Events + Redis |
| **3** | User accounts with persistent stats | PostgreSQL |
| **3** | Leaderboards displaying | Stats aggregation |
| **4** | Export API working | Events stored |
| **4** | Replay UI functional | Export API |
| **5** | Dockerized deployment | All above |
| **5** | Production deployment | Docker + hosting |
---
## Open Questions
1. **Guest play vs required accounts?**
- Recommendation: Allow guest play, prompt to register to save stats
2. **Game history retention?**
- Keep all events forever? Or archive after 90 days?
- Events are small (~500 bytes each), storage is cheap
3. **Replay visibility?**
- All games public? Only if shared? Privacy setting per game?
4. **CPU games count for leaderboards?**
- Recommendation: Yes, but flag them. Separate "vs humans" stats later.
5. **i18n approach?**
- Client-side translation files (JSON)
- Server messages are mostly game state, not text
---
## Appendix: File Structure (Proposed)
```
golfgame/
├── client/ # Frontend (keep as-is for now)
│ ├── index.html
│ ├── app.js
│ └── ...
├── server/
│ ├── main.py # FastAPI app, WebSocket handlers
│ ├── config.py # Settings (env vars)
│ ├── models/
│ │ ├── events.py # Event dataclasses
│ │ ├── game_state.py # State rebuilt from events
│ │ └── user.py # User model
│ ├── stores/
│ │ ├── event_store.py # PostgreSQL event persistence
│ │ ├── state_cache.py # Redis live state
│ │ └── user_store.py # User/auth persistence
│ ├── services/
│ │ ├── game_service.py # Command handling, event emission
│ │ ├── replay_service.py # Export, import, playback
│ │ ├── stats_service.py # Leaderboard queries
│ │ └── auth_service.py # Authentication
│ ├── workers/
│ │ └── stats_worker.py # Background stats aggregation
│ ├── ai/
│ │ ├── profiles.py # CPU personalities
│ │ └── decisions.py # AI logic
│ └── tests/
│ ├── test_events.py
│ ├── test_replay.py
│ └── ...
├── migrations/ # Alembic migrations
├── docker-compose.yml
├── Dockerfile
└── V2_BUILD_PLAN.md # This file
```
---
## Next Steps
1. **Review this plan** - Any adjustments to scope or priorities?
2. **Set up PostgreSQL locally** - For development
3. **Define event classes** - Start with Phase 1
4. **Add event emission** - Non-breaking change to current code
Ready to start building when you are.