Files
weirsoe-party-protocol/docs/PROTOCOL.md
T
agwandClaude Opus 5.5 5de261acd7 Tegn og Gæt: kæden slutter altid på et gæt
Ved et lige antal spillere får kæden et trin mere: i trin 1 tegner
ejeren sit eget ord, og rotationen starter et trin senere. Antal trin
hedder nu steps og bruges i step-eventet, snapshot og afsløringen;
self.n er antal spillere. Snapshot får step.own til trin 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:59:59 +02:00

26 KiB
Raw Blame History

Protokol: server ↔ klient

Al spilkommunikation går over én WebSocket pr. skærm. Serveren er autoritativ: klienten sender handlinger og modtager hele tilstanden (et snapshot tilpasset modtagerens rolle) efter hver ændring. Klienten gemmer intet spil-state ud over sin token og tegner altid fra seneste snapshot.

Endpoints

Sti Hvem Hvad
GET / spillere/publikum Join-side (?code=ABCD udfylder koden)
GET /host/ vært (Authentik) Spilmenuen (?code=ABCD: vælg næste spil i et kørende rum)
GET /host/intro/<spil> vært Speakerens intro til menuen: {"url", "ms"}, eller {} uden stemme
POST /host/new vært game=<spil> + spillets indstillinger (+ code ved skift); redirect til /host/<CODE>/
GET /host/<CODE>/ vært Storskærm
GET /qr/<CODE>.svg alle QR-kode til https://<host>/?code=<CODE>
WS /ws/play/<CODE>/ spillere/publikum
WS /ws/host/<CODE>/ vært (Authentik)

Koder er 4 store bogstaver (BCDFGHJKLMNPQRSTVWXZ), fx KLMT.

Spillene og skift af spil

Spillene står i ét register, party/games.py: nøgle, navn, tagline, min/max spillere, ca. minutter, trin, ikon, spillets egne skærme (JS), indstillinger og en funktion, der laver rummet. Menuen, /host/new, storskærmen og telefonen læser derfra; et nyt spil registrerer sig dér, og dets JS melder sig i window.WPPGameUI[<nøgle>].

Spil game Indstillinger på /host/new
Fup og Fakta fof pack = alle | familie | nørd | arbejde | fest
Tegn og Gæt tegn rules = 1 (vis reglerne først) | 0

Uden game oprettes det første spil i registret (Fup og Fakta); et ukendt spil giver 400, ukendte indstillinger giver spillets standard. Hvert snapshot, til storskærm og telefon, har "game".

Skift af spil: podiet har "Vælg andet spil", som går til /host/?code=ABCD. Menuen sender code med, og så laves det nye spil under samme kode: spillerne kommer med i den rækkefølge de kom, med samme navn og token; er der flere end det nye spils max, bliver resten publikum. Point starter forfra. Telefonerne bliver på deres WebSocket og får det nye spils lobby som et almindeligt state; de skal ikke joine igen, og resume med det gamle token virker. Det gamle rum lukkes. Mens værten er i menuen, viser telefonerne stadig podiet.

Klient → server

Alle beskeder er JSON {"action": "...", ...}.

Spiller/publikum (/ws/play/)

action felter hvornår
join name (1-12 tegn) første gang. Svar: joined. Er der 8 spillere, eller er spillet i gang, bliver man publikum.
resume token ved genforbindelse (token fra localStorage["wpp:"+code]). Ukendt token → error unknown_token, klienten viser join-formularen igen.
start VIP (første spiller) starter fra lobby
choose_category category_id kun den valgte spiller i fasen category
submit_lie text (1-40 tegn) fase lie
lie_for_me fase lie; svar: suggestion
guess answer_id fase guess; spillere og publikum, én gang
like answer_id kun fase reveal; slår til/fra; ikke eget svar og ikke sandheden (answer_invalid)
react emoji (en af tilstandens reactions: 😂 😱 👏 🤯 🙈 🔥, i fest-spil 💀 😭 🧢 🗿 💅 🍻) fase present, guess, reveal; må spammes (serveren dropper stille over 6/s pr. person). Intet svar; storskærmen får react. Tælles på det svar, der er i fokus på storskærmen.
play_again VIP i fasen final
ping svar pong

Vært (/ws/host/): start, skip (afslut nuværende fase nu), play_again, kick (player_id), ping.

Server → klient

type felter
state state: snapshot (se nedenfor)
joined token, player_id, role (player/audience)
suggestion text (svar på lie_for_me)
tease text, image (URL), sound (URL eller null) – kun til den spiller, der lige faldt for en løgn, i det øjeblik stemplet "LØGN" lander på storskærmen. Telefonen viser et fuldskærms-drilleri (~2,5 s, lukkes ved tryk), spiller boo og derefter sound, hvis lyden er låst op. Højst 2 pr. spørgsmål.
react emoji, color (afsenderens farve), answer_id (svaret i fokus eller null) – kun til storskærmen; emojien flyver op over skærmen.
error code, message (dansk, kan vises direkte)
pong

Fejlkoder: unknown_room, unknown_token, name_taken, name_invalid, not_allowed, wrong_phase, lie_is_truth, lie_invalid, already_done, answer_invalid, too_few_players.

Snapshot

{
  "game": "fof",               // spillets nøgle i party/games.py
  "code": "KLMT",
  "phase": "lobby",            // lobby | category | intro | lie | present | guess | reveal | scores | final
  "server_time": 1790000000000,// ms epoch; klienten udregner sin forskydning
  "deadline": 1790000060000,   // ms epoch eller null
  "join_url": "https://party.weircon.dk/?code=KLMT",
  "min_players": 2, "max_players": 8,
  "round": {"number": 1, "name": "Runde 1", "multiplier": 1,
            "question_number": 2, "questions_in_round": 3},          // null i lobby/final
  "players": [
    {"id": "p1", "name": "Asger", "color": "#ff4d6d", "score": 1500,
     "connected": true, "vip": true, "done": false}                 // done = har løjet/gættet i nuværende fase
  ],
  "audience_count": 0,
  "category": {"chooser_id": "p2", "options": [{"id": 3, "name": "Dyreriget"}],
               "chosen_id": null},                                   // kun i fase category; id når der er valgt (ellers null)
  "intro": {"category": "Dyreriget", "slug": "dyreriget", "final": false},  // kun i fase intro
  "question": {"category": "Dyreriget", "prompt": "En flok _____ kaldes et parlament.",
               "slug": "dyreriget"},                                 // lie/present/guess/reveal
  "present": {                                                       // kun i fase present (runde 2 og finalen)
    "started_at": 1790000000000,                                     // ms epoch; null = serveren laver stadig lyd
    "intro_ms": 1500, "steps_ms": [3100, 2900],                      // speakerens intro, så ét svar ad gangen
    "ids": ["a3", "a1"],                                             // oplæsningens rækkefølge (steps_ms/audio følger den)
    "intro_audio": {"url": "…", "ms": 1200},                         // eller null
    "audio": [{"url": "…", "ms": 2600, "text": "Krager. Det giver da mening…"}]  // pr. svar eller null
  },
  "answers": [{"id": "a1", "text": "ugler"}],                        // present + guess + reveal (i reveal med "likes"); egen løgn udeladt i present/guess
  "reveal": {                                                        // kun i fase reveal
    "steps": [                                                       // løgne først (kun dem der blev valgt), sandheden sidst
      {"answer_id": "a3", "text": "krager", "is_truth": false, "house": false,
       "authors": ["p2", "p5"], "assisted": ["p5"],                 // assisted = forfattere der brugte "Lyv for mig" uændret
       "guessed_by": ["p1", "p3"], "audience_votes": 2, "likes": 1,
       "points": [{"player_id": "p2", "delta": 1000}, {"player_id": "p5", "delta": 500}],
       "audio": {"url": "/media/tts/…mp3", "ms": 3100, "text": "…"}},   // speakerens replik til trinnet eller null
      {"answer_id": "a1", "text": "ugler", "is_truth": true, "house": false,
       "authors": [], "assisted": [], "guessed_by": ["p4"], "audience_votes": 1, "likes": 0,
       "points": [{"player_id": "p4", "delta": 1000}], "audio": null}
    ],
    "all": [                                                         // opsummeringen: ALLE svar, også dem ingen valgte
      {"id": "a7", "text": "skildpadder", "is_truth": false, "house": true,
       "authors": [], "assisted": [], "picked": false, "likes": 0,  // picked=false → svaret "eksploderer"
       "reactions": 3}                                               // emojis sendt, mens svaret var i fokus
    ],
    "summary": {                                                     // opsummeringens tidsplan i ms fra halens start, eller null
      "start_ms": 1200, "explode_at": 3900, "points_at": 7200,       // ikke-valgte eksploderer fra explode_at (250 ms imellem); point tælles fra points_at
      "audio": [{"url": "…", "ms": 2100, "at": 1600}]                // speakerens "lad os rydde op" / "så til pointene", afspilles ved at
    },
    "deltas": [{"player_id": "p1", "delta": 1500}],                  // point i dette spørgsmål pr. spiller (inkl. hjerter)
    "step_ms": 4000,                                                 // standardlængde pr. trin (bagudkompatibel)
    "steps_ms": [4200, 3900],                                        // faktisk længde pr. trin (følger speakerens replik)
    "tail_ms": 8000,                                                 // opsummeringen efter sidste trin
    "started_at": 1790000001000,                                     // ms epoch; null = serveren laver stadig lyd (vis "trommehvirvel")
    "note": "Forklaring på faktummet",                               // vises under sandheden
    "note_audio": {"url": "/media/tts/…mp3", "ms": 5200}            // forklaringen læst op (starter med sandhedens trin) eller null
  },
  "deltas": [{"player_id": "p1", "delta": 1500}],                    // fase scores: point i den netop afsluttede runde
  "final": {                                                         // kun i fase final
    "ranking": [{"player_id": "p1", "score": 9000, "place": 1}],
    "awards": [{"title": "Største løgner", "player_id": "p2", "detail": "narrede 7 gange"}]
  },
  "me": {                                                            // kun til spiller/publikum
    "id": "p1", "name": "Asger", "role": "player", "vip": true, "score": 1500,
    "lie": "krager",              // egen løgn i nuværende spørgsmål eller null
    "own_answer_id": "a3",        // id på eget svar i guess/reveal (kan ikke gættes eller likes)
    "guess": "a3",                // eget gæt eller null
    "likes": ["a2"],
    "is_chooser": false,
    "last_delta": 1500            // point fra seneste spørgsmål (reveal/scores)
  }
}

Værten får samme snapshot uden me, men med "host": true og desuden:

{
  "phase_key": "1.0.lie",          // "<runde>.<spørgsmål>.<fase>" – identificerer den aktuelle skærm
  "narration": [                   // speakerens seneste replikker (rullende vindue)
    {"id": 41, "url": "/media/tts/…mp3", "ms": 2300, "at": 1790000000000, "text": "…",
     "key": "1.0.intro"}           // den skærm (phase_key) replikken hører til; er skærmen væk, springes den over
  ]
}

Klienten afspiller nye replikker (id > sidst sete) i rækkefølge; ved første snapshot kun dem fra de sidste ~4 s. Afsløringens replikker ligger i reveal.steps[].audio / reveal.note_audio og startes af tidslinjen.

Afsløringens tidslinje

Trin i starter ved started_at + sum(steps_ms[0..i-1]). Inden for et trin vises først svaret, så hvem der valgte det (~28 % / højst 1,1 s), så stemplet (~55 % / højst 2,3 s). Efter sidste trin kører opsummeringen i tail_ms: alle svar (all), de ikke-valgte eksploderer, og deltas tælles op. deadline = slutningen af halen. Mens started_at er null, venter serveren på speakerens lydfiler (højst ~10 s) og sætter så tiderne. Opsummeringen følger summary: speakeren siger noget i stil med "dem her troede ingen på, lad os rydde op", så eksploderer de ikke-valgte svar, så "og så til pointene", og først derefter tælles pointene op.

Oplæsningens tidslinje (fasen present)

I runde 2 og finalen læser speakeren svarene op, før der gættes: først intro_ms, så svar i fra started_at + intro_ms + sum(steps_ms[0..i-1]). Hvert svar præsenteres overbevisende (med hjernen en kommentar pr. svar, som aldrig må afsløre, hvad der er sandt). Storskærm og telefoner viser svarene i samme takt; der kan først gættes i guess. deadline = slutningen + 1 s.

Lyd- og billedfiler

Templates får assets = {"sfx": {navn: url}, "tease": [url]} indlejret med {{ assets|json_script:"wpp-assets" }}. URL'erne er versionerede (hashede filnavne i drift → caches for altid); klienten slår alle lyde op her og falder tilbage til /static/party/sfx/<navn>.mp3. Kategoriens jingle hedder cat_<slug> (fx cat_mad_og_drikke); mangler den, bruges category.

Spillets gang (Fibbage-klassisk)

Runde Spørgsmål Point: rigtigt gæt / pr. narret spiller
Runde 1 3 1000 / 500
Runde 2 3 2000 / 1000
Finale 1 3000 / 1500

For hvert spørgsmål: category (15 s; skiftende spiller vælger blandt 4; i finalen springes fasen over) → intro (≈5 s; storskærmen præsenterer kategorien med sin jingle, speakeren siger kategorien, og serveren venter til speakeren er færdig) → lie (60 s, speakeren læser spørgsmålet op; slutter når alle har løjet) → guess (30 s, slutter når alle spillere har gættet) → reveal (automatisk: sum(steps_ms) + tail_ms; værten kan skip). Efter sidste spørgsmål i en runde: scores (10 s). Efter finalen: final.

Rækkefølge: category → intro → lie → guess → reveal (→ scores); i runde 2 category → intro → lie → present → guess → reveal (→ scores); i finalen intro → lie → present → guess → reveal → final.

  • Løgn lig sandheden (eller næsten lig) afvises med lie_is_truth. Ens løgne slås sammen, og alle forfattere får point.
  • Spillere, der ikke løj i tide, får ingen løgn; huset fylder op med lokkesvar, så der er mindst max(4, spillere+1) svar.
  • Man kan ikke vælge sin egen løgn.
  • Hjerter (like) gives kun under reveal: +100 til forfatteren pr. hjerte (uden multiplikator); ikke til sandheden og ikke til eget svar.
  • "Lyv for mig": bruger man forslaget uændret, får man kun halve point for at narre andre (forfatteren står i assisted).
  • Publikum: forfatteren får round(500 × multiplikator × andel af publikum narret).
  • Spil igen: samme spillere, point nulstilles.

Fest-Z (pakken "fest", 18+)

Oprettes med pack=fest på /host/new. Snapshot har altid fest (bool). I et fest-spil desuden:

  • players[].sips: tår i alt.
  • reveal.steps[].sips: [{player_id, n}]; n = rundens gange (×1/×2/×3, finalen vises som shots). everyone_drinks: true på sandhedens trin, når ingen fandt den.
  • reveal.sip_deltas: [{player_id, n}] for spørgsmålet (opsummeringen tæller tår op i stedet for point).
  • me.sips, me.last_sips (sidstnævnte kun i reveal).
  • final.awards får kind: "sips" ("Tørstigste").
  • Reglerne (rules.items) er fest-udgaven, og speakeren bruger <begivenhed>_fest-replikker og FEST_VOICE_ID.

Tegn og Gæt

Oprettes med game=tegn på /host/new (se Spillene og skift af spil). Samme endpoints, samme join/resume/ping/react, og samme joined/state/error. Snapshot har "game": "tegn". 3-10 spillere; dem der joiner efter start, bliver publikum (de kan give hjerter).

Med N spillere er der N kæder, én pr. spiller: trin 0 er et ord, ulige trin tegnes, lige trin gættes, og kæden slutter altid på et gæt. Ulige N: N trin; i trin t arbejder spiller i (rækkefølgen ved start) på kæde (i - t) mod N. Lige N: N+1 trin; i trin 1 tegner ejeren sit eget ord (step.own: true), og i trin t >= 1 arbejder spiller i på kæde (i - (t-1)) mod N. Ingen gætter på sin egen kæde.

Faser: lobby → rules (kun rummets første spil) → theme (20 s) → word (30 s) → draw (75 s) / guess (30 s) skiftevis → reveal → final. Et trin slutter, når alle forbundne spillere har afleveret, eller når tiden er gået. Så fyldes det manglende ud med spillerens kladde, ellers et eksempel-ord fra temaet, en tom tegning eller ???. Spillet hænger aldrig.

Spiller (/ws/play/)

action felter hvornår
start VIP'en (første spiller) i lobbyen
choose_theme theme_id VIP'en i fasen theme
submit_word text (1-30 tegn) fase word
suggest fase word; svar suggestion med et eksempel-ord, som ingen andre har skrevet
submit_drawing strokes (se nedenfor) fase draw
submit_guess text (1-30 tegn) fase guess
draft strokes (fase draw) eller text undervejs; bruges, hvis tiden løber ud. Intet svar, og en ugyldig kladde ignoreres stille
like entry_id fase reveal; slår til/fra; kun tegninger og gæt, som storskærmen har vist, og ikke ens egne. +100 til den, der lavede bidraget
react emoji fase reveal; som i Fup og Fakta (answer_id i react er bidraget i fokus)
play_again VIP'en i fasen final

Vært (/ws/host/): som Fup og Fakta, plus choose_theme (theme_id). skip i reveal springer til næste kæde (efter sidste kæde: final); i de andre faser afslutter det fasen.

Tegninger er vektorstrøg, ikke billeder: [{"color": "#e63946", "width": 0.008, "points": [[x, y], ...]}]. Koordinaterne er normaliserede 0..1 på et lærred i forholdet 4:3, width er en andel af bredden (0.002..0.06). Farven skal være en af #1d1b2e #e63946 #2a7de1 #2bb673. Serveren afviser (drawing_invalid) mere end 400 strøg, mere end 20.000 punkter i alt, mere end 64 KB JSON, punkter uden for lærredet og alt andet, der ikke har formen ovenfor. Punkterne rundes til 4 decimaler.

Server → klient, ud over Fup og Fakta: drawings (kun til storskærmen, én gang pr. afsløring og ved genforbindelse, før state): {"type": "drawings", "reveal_id": 1, "drawings": {"c0e1": [strøg...]}}. Tegningerne følger ikke med i snapshottet, så et hjerte ikke sender dem alle igen.

Fejlkoder ud over Fup og Fakta: word_invalid, guess_invalid, drawing_invalid.

{
  "game": "tegn",
  "phase": "draw",                  // lobby | rules | theme | word | draw | guess | reveal | final
  "min_players": 3, "max_players": 10,
  "round": null,
  "players": [{"id": "p1", "name": "Asger", "color": "#ff4d6d", "score": 0, "connected": true, "vip": true,
               "done": false}],     // done = har afleveret i trinnet
  "rules": {...},                   // fase rules, som Fup og Fakta
  "theme": {"options": [{"id": "dyr", "name": "Dyr", "icon": "🐘"}], "chosen_id": null},  // fase theme
  "theme": {"name": "Dyr", "icon": "🐘"},                                                // word/draw/guess/reveal
  "step": {"index": 1, "number": 2, "of": 5, "kind": "draw", "own": false},             // word/draw/guess; own = tegn dit eget ord
  "reveal": {
    "id": 1,
    "started_at": 1790000000000,    // ms epoch; null = serveren laver stadig lyd
    "plan": [                       // tidslinjen: pr. kæde en intro (entry -1), hvert bidrag, og slutningen (entry = antal trin);
                                    // audio (speakerens replik) kun til storskærmen, da slutningen nævner sidste gæt
      {"chain": 0, "entry": -1, "ms": 2500, "audio": null},
      {"chain": 0, "entry": 0, "ms": 3000, "audio": null},
      {"chain": 0, "entry": 5, "ms": 4500, "audio": {"url": "…", "ms": 3200, "text": "Fra kat til sofa."}}
    ],
    "chains": [{"owner": "p1", "survived": false,          // survived: sidste gæt = første ord (normaliseret)
                "entries": [{"id": "c0e0", "kind": "word", "by": "p1", "text": "kat", "auto": false, "likes": 0},
                            {"id": "c0e1", "kind": "drawing", "by": "p2", "text": null, "auto": false, "likes": 2}]}]
  },
  "final": {"ranking": [...], "awards": [{"title": "Bedste tegning", "player_id": "p2", "detail": "3 hjerter",
            "kind": "drawing"}]},   // kind: drawing | guess | survived | transformed
  "me": {
    "id": "p1", "name": "Asger", "role": "player", "vip": true, "score": 0,
    "can_choose": false,            // fase theme: VIP'en vælger
    "likes": ["c0e1"],              // fase reveal
    "task": {                       // word/draw/guess; null for publikum
      "kind": "draw", "done": false,
      "from": "p5",                 // hvem det forrige bidrag er fra
      "text": "giraf",              // det, der skal tegnes (fase draw); ellers null
      "drawing": null,              // tegningen, der skal gættes (fase guess); ellers null
      "mine": null,                 // eget ord/gæt, når det er afleveret
      "draft": null                 // serverens kladde (genindlæst side)
    }
  }
}

Telefonen ser kun sit eget trin (forrige ord eller forrige tegning), aldrig resten af kæden før afsløringen. Under afsløringen får telefonerne kun de bidrag, storskærmen har vist (og survived først, når kædens slutning er vist; indtil da null); rummet sender et nyt snapshot, hver gang et trin begynder. Storskærmen får hele afsløringen og styrer selv tempoet ud fra started_at og plan[].ms.

Point: +100 pr. hjerte til den, der lavede bidraget, og +500 til ejeren af en kæde, der overlevede. Priser: "Bedste tegning" og "Bedste gæt" (flest hjerter, ved lighed det første i afsløringen), "Kæden der overlevede" og "Største forvandling" (mindst lighed mellem første ord og sidste gæt). Temaerne står i party/data/themes.json og speakerens replikker i tegnoggaet/narrator/lines.json.

Spionen

Oprettes med game=spion på /host/new med rounds = 3 | 4 | 5 (standard 4) og rules = 1 | 0. Samme endpoints, samme join/resume/ping/react, og samme joined/state/error. Snapshot har "game": "spion". 4-10 spillere; dem der joiner efter start, bliver publikum. Temaerne er fælles med Tegn og Gæt (party/data/themes.json); Spionen bruger kun de konkrete navneord på mindst tre bogstaver (party/themes.py, spy_themes()).

En runde: alle undtagen spionen (to spioner fra otte spillere) ser et hemmeligt ord; spionen ser kun temaet. Ingen er spion to runder i træk, hvis det kan undgås. Faser: lobby → rules (kun rummets første spil) → clue (40 s) → clues (stikordene ét ad gangen på storskærmen, tilfældig rækkefølge) → talk (60 s) → vote (30 s) → verdict (7 s) → guess (30 s, kun hvis den anklagede er spion) → result (10 s), så næste runde eller final. clue og vote slutter, når alle forbundne spillere er færdige; alle tidsfaser kan springes over af værten. Spillet hænger aldrig: manglende stikord og stemmer løber ud, går den anklagede spion under gættet, slutter runden, og er der færre end fire spillere tilbage, slutter spillet efter runden.

Stikord: ét ord, højst 20 tegn, ikke temaet. For dem, der kender ordet, heller ikke ordet selv, en bøjning af det (en/et/er/erne/ene/e/s, også med fordoblet konsonant og uden slut-e), et ord, hvor det står inde i (hundehus), eller et stikord, en anden har skrevet i runden. Spionens stikord holdes hverken op mod ordet eller de andres (en afvisning ville røbe dem). Store/små bogstaver, mellemrum og bindestreger tæller ikke.

Afstemning: ikke på sig selv; flest stemmer bliver anklaget; lighed eller ingen stemmer = ingen anklages, og spionerne slipper. Publikum kan stemme for sjov (audience i votes, tæller ikke). Med to spioner er den anklagede fanget og får gættet; den anden slap. Spionens gæt er rigtigt, hvis det er ordet med de samme regler som stikord, plus små stavefejl (ingen under fire bogstaver, én op til syv, to derover).

Point: +100 for at stemme på en spion; +300 til en spion, der slap; +200 til en fanget spion, der gætter ordet. Priser: Bedste løgner (flest point som spion), Skarpeste øje (flest rigtige stemmer), Mest mistænkt (flest stemmer).

Spiller (/ws/play/)

action felter hvornår
start VIP'en i lobbyen
submit_clue text (ét ord, 1-20 tegn) fase clue; må skiftes, til fasen slutter
vote player_id fase vote; må skiftes; publikum også
spy_guess text (1-30 tegn) fase guess, kun den anklagede spion
react, play_again som i Tegn og Gæt

Fejlkoder ud over Fup og Fakta: clue_invalid, clue_is_word, clue_is_theme, clue_taken, bad_vote, not_in_round, guess_invalid.

Hemmeligheden: ordet står kun i me.card til dem, der kender det, aldrig til spionen, publikum eller storskærmen før result. Hvem der er spion står ingen steder før dommen: ikke i spillerlisten, ikke i rækkefølger eller tider. votes.accused_is_spy får storskærmen under verdict (den afslører det i fasens sidste sekunder); telefonerne først i guess. Telefonerne får ingen stikord i clues (de kigger på storskærmen). Speakeren siger aldrig ordet eller spionen før result; dommens replikker spørger kun.

{
  "game": "spion",
  "phase": "vote",                  // lobby | rules | clue | clues | talk | vote | verdict | guess | result | final
  "round": {"number": 2, "of": 4, "name": "Runde 2 af 4"},
  "players": [{"id": "p1", "name": "Asger", "color": "#ff4d6d", "score": 100, "connected": true, "vip": true,
               "done": false, "in_round": true}],   // done = har skrevet stikord (clue) / stemt (vote)
  "theme": {"name": "Transport", "icon": "🚂"},
  "spies": 1,                       // hvor mange, ikke hvem
  "clues": {"items": [{"player_id": "p3", "text": "vinger"}, {"player_id": "p1", "text": null}]},
  // fase clues (kun storskærmen har items): {"started_at", "intro_ms", "clue_ms", "items"}
  "votes": {"counts": [{"player_id": "p2", "votes": 4}], "audience": [{"player_id": "p2", "votes": 1}],
            "accused": "p2", "accused_is_spy": true, "by": []},   // verdict/guess/result; by (hvem stemte på hvem) i result
  "result": {"spies": ["p2"], "spy_names": ["Sus"], "word": "flyvemaskine", "caught": "p2",
             "guess": {"text": "helikopter", "correct": false}, "spy_won": false, "deltas": {"p1": 100}},
  "me": {"id": "p1", "role": "player", "card": {"spy": false, "theme": "Transport", "word": "flyvemaskine"},
         "clue": "vinger", "vote": "p2", "can_guess": false}
}