Captures all brainstormed decisions: - Pluggable game cartridge platform (GameDriver interface) - Celery + Redis timer-driven phase transitions - Session owner play/pause/exit controls (no skip) - Escalating scoring per round, incremental reveal scoring - Emoji reactions during guess phase → post-game awards - Relational per-user config presets with game-specific models - Ephemeral game state (no persistence after exit/finish) - Full WebSocket event reference and data lifecycle Also: updated TODO.md (WebSocket done, persisted answers done), created CLAUDE.md, and PROMPT.md for ralph-loop. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
273 lines
10 KiB
Markdown
273 lines
10 KiB
Markdown
# Design: Fup og Fakta — Game Engine & Platform Architecture
|
||
|
||
**Date:** 2026-03-09
|
||
**Status:** Approved
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
Build a working Fup og Fakta game (Fibbage-style) on top of a **pluggable game platform**. The platform handles sessions, players, WebSocket push, and Celery-driven timers. Each game is a self-contained **cartridge** that implements a shared driver interface and owns its own models, config, and phase logic.
|
||
|
||
---
|
||
|
||
## Platform Architecture
|
||
|
||
```
|
||
partyhub/ Django project — settings, Celery app, ASGI
|
||
lobby/ Platform layer — sessions, players, GameRun, timer dispatch
|
||
realtime/ WebSocket consumers (already built)
|
||
fupogfakta/ Game cartridge #1
|
||
future_game/ Game cartridge #N (same interface)
|
||
```
|
||
|
||
### Platform provides (`lobby/`)
|
||
|
||
#### Models
|
||
|
||
**`GameSession`** (exists, minor additions)
|
||
- `game_type` (CharField) — e.g. `"fupogfakta"`
|
||
- `host` (FK → User)
|
||
- `code` (6-char session code)
|
||
- `status` (LOBBY / ACTIVE / FINISHED)
|
||
- `config_id` / `config_snapshot` — see Config section
|
||
|
||
**`GameRun`** (new — ephemeral, deleted on game exit)
|
||
- `session` (OneToOne → GameSession)
|
||
- `current_state` (CharField — game-defined state string)
|
||
- `phase_deadline` (DateTimeField, nullable)
|
||
- `is_paused` (BooleanField, default False)
|
||
- `paused_remaining_seconds` (FloatField, nullable)
|
||
- `celery_task_id` (CharField, nullable)
|
||
- `state_data` (JSONField) — game-specific snapshot for current phase
|
||
|
||
**`Player`** (exists)
|
||
- `session`, `nickname`, `score`, `session_token`, `is_connected`
|
||
|
||
#### GameDriver interface
|
||
|
||
Each cartridge implements:
|
||
|
||
```python
|
||
class GameDriver:
|
||
game_type: str # e.g. "fupogfakta"
|
||
|
||
def on_game_start(session, run, config) -> PhaseResult
|
||
def on_timer_expired(session, run, config) -> PhaseResult
|
||
def on_pause(session, run) -> None
|
||
def on_resume(session, run) -> None
|
||
def on_exit(session, run) -> None # must clean up all game data
|
||
def get_ws_payload(state, state_data) -> dict
|
||
```
|
||
|
||
`PhaseResult` = `(next_state: str, duration_seconds: int | None, broadcast_payload: dict)`
|
||
|
||
#### Celery task
|
||
|
||
```python
|
||
@app.task
|
||
def handle_timer_expired(run_id: int, expected_state: str):
|
||
# If run no longer exists or state has changed → stale task, ignore
|
||
# Call driver.on_timer_expired(session, run, config)
|
||
# Apply PhaseResult: update run, broadcast via channel layer, schedule next task
|
||
```
|
||
|
||
`expected_state` prevents stale tasks from firing after pause/resume or manual state changes.
|
||
|
||
#### REST endpoints (platform-level)
|
||
|
||
- `POST /sessions/{code}/play` — start or resume
|
||
- `POST /sessions/{code}/pause` — pause current phase timer
|
||
- `POST /sessions/{code}/exit` — end game, delete GameRun + all game data
|
||
|
||
---
|
||
|
||
## Configuration System
|
||
|
||
### Base config model (`partyhub/`)
|
||
|
||
```python
|
||
class BaseGameConfig(models.Model):
|
||
class Meta:
|
||
abstract = True
|
||
|
||
name = models.CharField(max_length=100) # "Quick game", "Full evening"
|
||
user = models.ForeignKey(User, null=True, ...) # null = system default
|
||
is_default = models.BooleanField(default=False)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
```
|
||
|
||
### Game-specific config (`fupogfakta/`)
|
||
|
||
```python
|
||
class FupOgFaktaConfig(BaseGameConfig):
|
||
num_rounds = PositiveIntegerField(default=3)
|
||
questions_per_round = PositiveIntegerField(default=3)
|
||
min_players = PositiveIntegerField(default=2)
|
||
max_players = PositiveIntegerField(default=8)
|
||
lie_seconds = PositiveIntegerField(default=45)
|
||
guess_seconds = PositiveIntegerField(default=30)
|
||
reveal_seconds_per_lie = PositiveIntegerField(default=8)
|
||
scoreboard_recap_seconds = PositiveIntegerField(default=10)
|
||
# Escalating scoring per round (stored as arrays or separate fields)
|
||
points_correct = JSONField(default=[1500, 3000, 4500])
|
||
points_bluff = JSONField(default=[500, 1000, 1500])
|
||
# Reaction bonus (static, feeds post-game awards only)
|
||
reaction_bonus = IntegerField(default=5)
|
||
```
|
||
|
||
### Default resolution at session start
|
||
|
||
1. User has `is_default=True` row for this game type → use that
|
||
2. System default (`user=null, is_default=True`) — set in Django admin
|
||
3. Model field `default=` values (hardcoded)
|
||
|
||
User can have **multiple named presets** (one-to-many). When starting a session they choose which to use (or it auto-selects their default). The chosen config's values are **snapshotted into `GameRun.state_data`** at game start — immutable for the life of the session.
|
||
|
||
---
|
||
|
||
## Fup og Fakta — Game States
|
||
|
||
```
|
||
LOBBY
|
||
│ (host presses Play)
|
||
▼
|
||
LIE_PHASE timer: lie_seconds
|
||
│ (all submitted OR timer expires)
|
||
▼
|
||
GUESS_PHASE timer: guess_seconds
|
||
│ (timer expires — no mercy)
|
||
▼
|
||
REVEAL_LIE_{n} timer: reveal_seconds_per_lie (one per lie with ≥1 guess)
|
||
│ → score liar incrementally as each is shown
|
||
▼
|
||
REVEAL_TRUTH timer: reveal_seconds_per_lie
|
||
│ → score correct guessers
|
||
▼
|
||
SCOREBOARD_RECAP timer: scoreboard_recap_seconds
|
||
│
|
||
├─ more questions in round → back to LIE_PHASE (next question)
|
||
├─ round done, more rounds → back to LIE_PHASE (next round, next category)
|
||
└─ all rounds done → POST_GAME_AWARDS
|
||
timer: configurable
|
||
→ FINISHED (GameRun deleted, GameSession status = FINISHED)
|
||
```
|
||
|
||
---
|
||
|
||
## Fup og Fakta — Phase Details
|
||
|
||
### LIE_PHASE
|
||
- Question shown to all clients via WebSocket (`phase.lie_started` event)
|
||
- Players submit lie via `POST /fupogfakta/{code}/lie`
|
||
- **If lie matches correct answer (case-insensitive):** return `error_code: lie_matches_correct_answer` — player prompted again, does not consume their submission
|
||
- Anonymous to other players during this phase
|
||
- `state_data` tracks: question id, round number, how many have submitted (for progress display on host screen)
|
||
- Timer expires → transition to GUESS_PHASE regardless of how many submitted
|
||
|
||
### GUESS_PHASE
|
||
- Answers mixed (lies + truth, deduped) broadcast to all clients (`phase.guess_started`)
|
||
- Players guess via `POST /fupogfakta/{code}/guess`
|
||
- **After selecting:** player can react to other lies with 👍 😂 ❤️ etc. until timer expires. Cannot change guess.
|
||
- Reactions stored in `LieReaction` model (player, lie, reaction_type)
|
||
- Timer expires → transition to first REVEAL_LIE (or REVEAL_TRUTH if no lies had guesses)
|
||
|
||
### REVEAL_LIE_{n}
|
||
- One Celery task per lie to reveal (only lies with ≥1 guesser)
|
||
- Broadcast: which lie, who wrote it, who guessed it (`phase.reveal_lie`)
|
||
- Score awarded to liar: `points_bluff[round_index] × guesser_count`
|
||
- Score broadcast immediately (`phase.score_delta`)
|
||
- Skipped lies (0 guesses): not shown at all
|
||
|
||
### REVEAL_TRUTH
|
||
- Broadcast: correct answer, who guessed correctly (`phase.reveal_truth`)
|
||
- Score awarded: `points_correct[round_index]` per correct guesser
|
||
- Also show reaction totals on each lie during this phase
|
||
|
||
### SCOREBOARD_RECAP
|
||
- Full leaderboard broadcast (`phase.scoreboard`)
|
||
- Auto-advances to next question, next round, or post-game
|
||
|
||
### POST_GAME_AWARDS
|
||
- Computed from `LieReaction` aggregate:
|
||
- "Most Hilarious Liar" — most 😂 reactions total
|
||
- "Most Beloved Lie" — most ❤️ reactions on a single lie
|
||
- etc. (extensible)
|
||
- Broadcast as `phase.awards`
|
||
- Then FINISHED → GameRun deleted, all session game data wiped
|
||
|
||
---
|
||
|
||
## Fup og Fakta — Models
|
||
|
||
**Existing (keep):** `Category`, `Question`, `RoundQuestion`, `LieAnswer`, `Guess`
|
||
|
||
**Remove:** `ScoreEvent` (no audit trail needed — game state is ephemeral)
|
||
|
||
**New:**
|
||
```python
|
||
class LieReaction(models.Model):
|
||
lie = ForeignKey(LieAnswer, on_delete=CASCADE)
|
||
player = ForeignKey(Player, on_delete=CASCADE)
|
||
reaction = CharField(max_length=20) # "laugh", "heart", "fire", etc.
|
||
created_at = auto_now_add
|
||
class Meta:
|
||
unique_together = [("lie", "player", "reaction")]
|
||
```
|
||
|
||
**Modify `RoundQuestion`:**
|
||
- Add `reveal_order` (PositiveIntegerField, nullable) — set when GUESS_PHASE ends, determines reveal sequence
|
||
|
||
---
|
||
|
||
## Pause / Resume
|
||
|
||
- **Pause:** compute `remaining = phase_deadline - now`, store in `paused_remaining_seconds`, set `is_paused=True`, revoke Celery task by `celery_task_id`
|
||
- **Resume:** set `phase_deadline = now + paused_remaining_seconds`, schedule new Celery task, clear pause fields
|
||
- Stale task guard: every Celery task checks `expected_state == run.current_state` before firing
|
||
|
||
---
|
||
|
||
## Host Controls (Session Owner Only)
|
||
|
||
| Action | Effect |
|
||
|--------|--------|
|
||
| Play | Starts game from LOBBY, or resumes from paused |
|
||
| Pause | Freezes current phase timer, broadcasts `phase.paused` |
|
||
| Exit | Ends game immediately, deletes GameRun + all game data |
|
||
|
||
Cannot skip. Cannot manually advance phases.
|
||
|
||
---
|
||
|
||
## WebSocket Event Reference
|
||
|
||
| Event | Triggered by | Payload |
|
||
|-------|-------------|---------|
|
||
| `phase.lie_started` | LIE_PHASE start | question prompt, deadline, round info |
|
||
| `phase.lie_progress` | Each lie submitted | n_submitted / n_players (no names) |
|
||
| `phase.guess_started` | GUESS_PHASE start | mixed answers, deadline |
|
||
| `phase.reveal_lie` | REVEAL_LIE_{n} | lie text, author, guessers, score delta |
|
||
| `phase.reveal_truth` | REVEAL_TRUTH | correct answer, correct guessers, score delta |
|
||
| `phase.scoreboard` | SCOREBOARD_RECAP | full leaderboard |
|
||
| `phase.awards` | POST_GAME_AWARDS | award winners |
|
||
| `phase.paused` | Pause | remaining_seconds |
|
||
| `phase.resumed` | Resume | new deadline |
|
||
| `phase.game_over` | FINISHED | final leaderboard |
|
||
|
||
---
|
||
|
||
## Data Lifecycle
|
||
|
||
All game session data (`GameRun`, `RoundQuestion`, `LieAnswer`, `Guess`, `LieReaction`, `Player`) is **deleted when host exits or game reaches FINISHED**. `GameSession` row is kept (with status=FINISHED) for the session code uniqueness constraint. `Category` and `Question` content is permanent.
|
||
|
||
---
|
||
|
||
## Not In Scope (This Implementation)
|
||
|
||
- TTS / read-aloud (Fase 4, deferred)
|
||
- Reconnect recovery after server restart (game is gone if server dies)
|
||
- Spectator/viewer mode (post-MVP)
|
||
- Rate limiting on endpoints (backlog)
|
||
- Bulk question import (Fase 5)
|