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>
10 KiB
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:
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
@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 resumePOST /sessions/{code}/pause— pause current phase timerPOST /sessions/{code}/exit— end game, delete GameRun + all game data
Configuration System
Base config model (partyhub/)
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/)
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
- User has
is_default=Truerow for this game type → use that - System default (
user=null, is_default=True) — set in Django admin - 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_startedevent) - 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_datatracks: 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
LieReactionmodel (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
LieReactionaggregate:- "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:
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 inpaused_remaining_seconds, setis_paused=True, revoke Celery task bycelery_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_statebefore 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)