# 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)