Faseskift venter, til speakeren er færdig med den aktuelle skærm, og replikker til en skærm, der er væk, afspilles ikke. Ny kategori-intro med en lyd pr. kategori, musik pr. fase med bløde overgange og lydeffekter til alt, der flyver over skærmen. Efter afsløringen eksploderer løgne, ingen valgte, og pointene tæller op. Hjerter gives kun under afsløringen og ses live på storskærmen. Lyv for mig giver halve point. Narrede spilleres telefon kan blive drillet med et fjollet billede og et buh. Alle lyd- og billedfiler serveres med versionerede navne og caches af browseren. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
10 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) |
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. |
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 | 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/guess/reveal
"answers": [{"id": "a1", "text": "ugler"}], // guess + reveal (i reveal med "likes"); egen løgn udeladt i 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"
],
"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.
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 finalen intro → lie → 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.