Move V2_BUILD_PLAN.md to docs/v2/
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
522
docs/v2/V2_BUILD_PLAN.md
Normal file
522
docs/v2/V2_BUILD_PLAN.md
Normal file
@@ -0,0 +1,522 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user