523 lines
17 KiB
Markdown
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.
|