docs: design doc for fup og fakta game engine + platform architecture
Some checks failed
CI / test-and-quality (push) Has been cancelled
CI / test-and-quality (pull_request) Successful in 2m43s

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>
This commit is contained in:
Asger Geel Weirsøe
2026-03-09 07:32:45 +01:00
parent f1699841e6
commit d2dbd8c802
13 changed files with 718 additions and 14 deletions

View File

@@ -20,6 +20,8 @@ from fupogfakta.models import (
ScoreEvent,
)
from realtime.broadcast import sync_broadcast_phase_event
from .i18n import api_error, lobby_i18n_errors
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
@@ -321,6 +323,16 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
session.status = GameSession.Status.LIE
session.save(update_fields=["status"])
sync_broadcast_phase_event(
session.code,
"phase.lie_started",
{
"round_number": session.current_round,
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
"lie_seconds": round_config.lie_seconds,
},
)
return JsonResponse(
{
"session": {
@@ -407,6 +419,18 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
sync_broadcast_phase_event(
session.code,
"phase.question_shown",
{
"round_question_id": round_question.id,
"prompt": question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
"lie_seconds": round_config.lie_seconds,
},
)
return JsonResponse(
{
"round_question": {
@@ -575,6 +599,22 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
locked_session.status = GameSession.Status.GUESS
locked_session.save(update_fields=["status"])
try:
_guess_config = RoundConfig.objects.get(session=session, number=session.current_round)
_guess_seconds = _guess_config.guess_seconds
except RoundConfig.DoesNotExist:
_guess_seconds = None
sync_broadcast_phase_event(
session.code,
"phase.guess_started",
{
"round_question_id": round_question.id,
"answers": [{"text": t} for t in deduped_answers],
"guess_seconds": _guess_seconds,
},
)
return JsonResponse(
{
"session": {
@@ -719,6 +759,12 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
.values("id", "nickname", "score")
)
sync_broadcast_phase_event(
session.code,
"phase.scoreboard",
{"leaderboard": list(leaderboard), "current_round": session.current_round},
)
return JsonResponse(
{
"session": {
@@ -792,6 +838,12 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
winner = leaderboard[0] if leaderboard else None
sync_broadcast_phase_event(
session.code,
"phase.game_over",
{"winner": winner, "leaderboard": list(leaderboard)},
)
return JsonResponse(
{
"session": {
@@ -898,6 +950,21 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
.values("id", "nickname", "score")
)
score_deltas = [
{"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason}
for ev in score_events
]
sync_broadcast_phase_event(
session.code,
"phase.scores_calculated",
{
"round_question_id": round_question.id,
"score_deltas": score_deltas,
"leaderboard": list(leaderboard),
},
)
return JsonResponse(
{
"session": {