Files
weirsoe-party-protocol/docs/plans/2026-03-09-fupogfakta-game-engine-design.md
Asger Geel Weirsøe d2dbd8c802
Some checks failed
CI / test-and-quality (push) Has been cancelled
CI / test-and-quality (pull_request) Successful in 2m43s
docs: design doc for fup og fakta game engine + platform architecture
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>
2026-03-09 07:35:55 +01:00

10 KiB
Raw Blame History

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

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

  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:

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)