PROTOCOL.md: /host/ er menuen (med ?code ved skift), /host/intro/<spil>, /host/new's felter pr. spil, "game" i alle snapshots (også Fup og Fakta's) og hvordan et skift af spil virker for telefonerne. README: menuen, skift af spil, hvor et nyt spil registreres, og dev.html?game=menu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
21 KiB
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: truepå 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 ireveal).final.awardsfårkind: "sips"("Tørstigste").- Reglerne (
rules.items) er fest-udgaven, og speakeren bruger<begivenhed>_fest-replikker ogFEST_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. I trin t (0..N-1) arbejder spiller i (rækkefølgen ved start)
på kæde (i - t) mod N: trin 0 er et ord, ulige trin tegnes, lige trin gættes. Ingen får sin egen kæde igen, og
hver kæde ender med N bidrag. Med et lige antal spillere ender kæden på en tegning; så er "sidste gæt" gættet før den.
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"}, // word/draw/guess
"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 N);
// 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 tegnoggaet/data/themes.json
og speakerens replikker i tegnoggaet/narrator/lines.json.