- Runde 2 og finalen: speakeren læser svarene op ét ad gangen (med hjernen en overbevisende kommentar pr. svar), i samme takt på storskærm og telefoner - Emoji-reaktioner fra telefonerne, der må spammes; de flyver op på storskærmen og tælles på svaret i fokus - Opsummeringen følger speakeren: "lad os rydde op", eksplosioner, så pointene - Speakeren kommenterer, hvordan løgnene skrider frem, uden at røbe indhold, og driller dem, der falder for svage løgne - Reglerne forklares med kort og stemme før rummets første spil - "Lyv for mig" låser forslaget i feltet (halve point), og feltet bliver ikke længere kæmpestort - Svar vises med versaler, og spillernes løgne gemmes med små bogstaver, så store forbogstaver ikke afslører dem - Løgne og navne renses: kun bogstaver, tal og lidt tegnsætning - Valgfrit workspace-id til Anthropic-brugernøgler Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
12 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) | Opret nyt spil |
POST /host/new |
vært | Opretter rum, 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.
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 😂 😱 👏 🤯 🙈 🔥) |
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
{
"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.