Een nieuw speltype toevoegen
Aan het einde van deze gids heb je een werkend nieuw speltype dat een
begeleider live in een activiteitenkamer kan kiezen, instellen en spelen —
inclusief lobby, gameplay, eindstand en alle realtime-broadcasts. Spellen met
een redactie-inhoud (vragen, woorden) beheert een Tapster-admin als content
via /admin/spellen; de begeleider kiest in de kamer welk spel gespeeld wordt
en stelt de spelregels per ronde in.
Lezen vóór je begint:
- Sectie Architectuur in vogelvlucht. Zonder dit mentale model zit je in losse bestanden te tikken zonder te zien hoe ze samen het verhaal vormen.
- De bestaande speltypes (
quiz,word-search,sudoku,bingo) zijn je werkende referentie. Bij elk nieuw stuk: kopieer eerst een bestaande variant, pas hem daarna aan. Niet vanaf nul beginnen. Kies de referentie die bij jouw spel past:quizenword-searchtrekken hun inhoud uit een redactie-pool die een admin in/admin/spellenbeheert.sudokuis het voorbeeld van een inhoudsloos, server-gegenereerd spel: het heeft géén admin-definitie en géén pool, en bestaat alleen in de lobby. Elke ronde wordt server-side gegenereerd.bingois óók inhoudsloos, maar het voorbeeld van een spel met gedeelde, host-gestuurde state (de host trekt nummers metbingo:draw) náást per-deelnemer state (iedere deelnemer krijgt lazy een eigen kaart viabingo:requestCarden markeert zelf). Kijk hier als jouw spel een host-bediend element heeft dat met individuele speler-voortgang combineert.
Architectuur in vogelvlucht
Een spel doorloopt vier verantwoordelijkheden, elk met een andere bron-van-waarheid:
| Verantwoordelijkheid | Wat | Waar bewaard | Wie schrijft |
|---|---|---|---|
| Definitie | De content van een spel (vragen, woorden, …) | Mongo, admin-DB | Tapster-admin via /admin/spellen (alleen pool-gebaseerde types) |
| Koppeling | Mag op deze card überhaupt gespeeld worden? | Mongo, tenant-DB (card.gamesEnabled, pure toggle) |
Begeleider via card-editor |
| Selectie + instellingen | Welk spel deze ronde + de spelregels (aantal, raster, vlaggen) | Redis-sessie | Begeleider live in de lobby |
| Sessie | Live state van één gespeelde ronde | Redis (CAS, TTL 6 uur) | Socket.io-handlers via gameplayService |
Bron-van-waarheid
- Definitie + koppeling zijn Mongo-data. Standaard CRUD via repositories.
De definitie bevat sinds de lobby-herinrichting alleen content (pool) +
tenant-koppeling +
isActive. Géén spelinstellingen meer. - Selectie + instellingen leven op de Redis-sessie. De begeleider kiest
in de lobby het soort spel, een voorgedefinieerd spel (bij pool-types) en de
instellingen (
sessionConfig+ de vlaggenparticipantsCanInteract/showLeaderboard). De gameDefinition levert geen instellingen aan. - Sessie is Redis met optimistic concurrency (zie
apps/api/src/realtime/gameplay/gameSessionStore.ts). Elke schrijfactie bumpt eenversion-veld; bij conflict gooit de storeVersionConflictError. Callers retryen door opnieuw teload-en.
Lagen op de backend
admin-games (definitie = alleen content/pool)
│
▼
card.gamesEnabled (toggle: mag hier gespeeld worden?)
│
▼
activityRouter:GET /activities/:id/game ← FE leest { gamesEnabled, cardName } (paneel tonen + kaartnaam in de header)
│
▼
Socket.io /gameplay namespace
│ (lobby) host kiest soort + spel + instellingen:
│ game:setGameType → game:selectGame → game:updateConfig → game:start
│
gameplayHandlers (dun — alleen event ↔ service-call ↔ broadcast)
│
gameplayService (orchestratie — DB-lookups, host-check, CAS, snapshot, broadcast-payload)
│
<game>Engine.ts (puur — geen Redis, geen socket, geen DB)
De engines zijn waar het spel-eigen gedrag zit. De service en de
handlers blijven generiek; ze switchen alleen op gameType om de juiste
build…State of apply…Action aan te roepen. Een nieuw speltype toevoegen is
dus vooral: een engine schrijven, hem in een paar switches inhaken, en een
lobby-instellingenform toevoegen.
Lagen op de frontend
WherebyComponent
├── header (host-only: puzzel-icoon + kaartnaam links, paneel-toggle rechts)
│ └── paneel-toggle (host-only via game:setPanelOpen)
├── iframe (Whereby video)
├── sleepbalk (host-only, md+: paneelbreedte via game:setPanelWidth)
└── GameContainerComponent
├── connect/disconnect GameSocketService
├── view = lobby | playing | finished (afgeleid van session.status)
├── GameLobbyComponent (view=lobby)
│ └── GameTypeSelectorComponent (host kiest het soort spel bovenaan het paneel)
├── HostActingBarComponent (view=playing, host-only: avatarrij "speel als deelnemer")
└── @switch (session.gameType)
├── 'quiz' → QuizGameComponent
├── 'word-search' → WordSearchGameComponent
├── 'sudoku' → SudokuGameComponent
└── (jouw nieuwe type)
De header is host-only en draagt geen spel-keuze meer: links een
puzzel-icoontje + de naam van de gekoppelde card (uit cardName), rechts de
paneel-toggle. De soort-keuze verhuisde naar het spelpaneel: bovenaan de
GameLobbyComponent staat de GameTypeSelectorComponent als dropdown
(standaard “Geen spel geselecteerd”). In de lobby kiest de host dus het
soort, daarna een voorgedefinieerd spel (bij pool-types) en de instellingen
(<type>-settings-form in shared/components/games/settings-forms/). Pas
daarna verschijnt de startknop.
Op laptop/desktop (md+) kan de host de breedte van het spelpaneel
verslepen via een grip-balk tussen video en paneel. De breedte is server-state
(session.panelWidth, percentage) zodat ze proportioneel meeschuift bij alle
deelnemers; tijdens slepen volgt het paneel van de host direct de cursor en
worden de tussenstanden throttled (~150ms) gebroadcast via game:setPanelWidth.
Zonder waarde valt het paneel terug op de standaardbreedte (33,3%, min 22rem).
Alleen de host ziet de balk; deelnemers schuiven mee maar verslepen niet.
GameSocketService is een singleton-wrapper rond Socket.io. Hij exposeert
session, status, lastError als signals; componenten lezen de session
reactief en sturen acties terug via sendAction(activityId, action).
Event-contract (socket)
Inkomend client → server (zie gameplayHandlers.ts):
- Gameplay:
game:join,game:start,game:action,game:stop,game:setPanelOpen,game:setPanelWidth,game:claimSelection,game:releaseSelection. - Lobby-selectie (host-only):
game:setGameType,game:selectGame,game:updateConfig.
Uitgaand server → client:
game:started,game:stateUpdate,game:finished,game:stopped,game:error.
Een nieuw speltype voegt geen nieuwe socket-events toe. De
lobby-selectie-events (setGameType / selectGame / updateConfig) zijn
type-agnostisch, en spelacties lopen allemaal via game:action met nieuwe
action-types binnen je type (bijv. word-search:wordFound).
Twee state-bewerkings-paden
Voor een nieuw speltype zijn er twee plekken waar je state bewerkt:
- Initial state — bij
game:startbouwt de engine een verse state uit de pool + sessionConfig. Hier sluit je aan opselectPoolIndices()uitpoolSelection.tszodat recent gebruikte items in dezelfde card-context worden vermeden. DesessionConfigkomt van de sessie (door de host in de lobby gezet), metdefaultSessionConfigFor(type)als fallback. - Action-handling — bij
game:actionbewerkt de engine de bestaande state op basis van een action-payload. Returnt{ state, finished: boolean }.finished: trueschakelt de sessie naarstatus: 'finished'. Tip: laat de engine bij voorkeur niet zelf auto-finishen op de “laatste vondst”-actie, maar gebruik een aparte host-only “showResults”-actie (zie hoeword-searchdat doet — geeft de groep ruimte om de laatste vondst te bespreken voordat het resultatenscherm verschijnt).
Spellen zonder content-pool (gegenereerd, definitie-loos)
Niet elk spel heeft een redactie-pool. sudoku genereert zijn puzzel
server-side bij game:start. Zo’n spel is definitie-loos: het heeft géén
gameDefinition in de admin, staat niet in de admin-Zod-union, en de host
hoeft in de lobby geen voorgedefinieerd spel te kiezen. Kenmerken:
- Geen admin-beheer: het type zit niet in
/admin/spellen, niet ingameDefinitionSchemaValidators.tsen heeft geen admin-form. - Altijd kiesbaar in de
GameTypeSelectorComponent(in tegenstelling tot pool-types, die pas verschijnen als er een actief spel in de catalogus staat). - In de lobby gaat de host direct van soort-keuze naar de
instellingen-form + start;
selectGamewordt niet gebruikt. - In de engine: een lege
pool({}),usedItems: []in de state en geenselectPoolIndices-aanroep (elke ronde is uniek), alle inhoud-logica inbuildInitial…State(genereren) i.p.v. uit een pool trekken.
De rest (sessie, broadcast, host-checks, instellingen via de lobby) is identiek
aan een pool-gebaseerd spel. Gebruik je een hint-/grootte-instelling, zet die als
eigen veld in de sessionConfig (zie sudoku’s size + hintLevel)
i.p.v. één veld dubbel te belasten — dat houdt de generatie-logica leesbaar en
geeft de host in de lobby fijner beheer.
De lobby-uitleg schrijven (GAME_TYPE_DESCRIPTIONS)
Elke entry in GAME_TYPE_DESCRIPTIONS is de uitleg die deelnemers (de
cliënten) vóór de start in de lobby zien. Dit is geen losse documentatie maar
UI-copy met een vast format. Bingo is de referentie voor de huidige
standaard.
Doelgroep en toon. Schrijf voor cliënten, niet voor de begeleider: korte zinnen, eenvoudig Nederlands (B1-niveau), geen vaktermen en geen em-dashes (repo-conventie voor NL-copy). Vaste volgorde: eerst wat je ziet, daarna hoe je meedoet.
Het render-contract. De lobby (gameDescriptionView in
game-lobby.component.ts) splitst de string op \n:
- Een regel die met
-begint wordt een opsommingspunt. - Elke andere niet-lege regel wordt een uitleg-zin (paragraaf).
- De lobby toont eerst alle uitleg-zinnen, daarna de opsomming.
Dit is de standaard voor alle speltypes: schrijf de uitleg als een array van
regels die je met '\n' joint. Eén of twee inleidende zinnen, daarna de
speelstappen als - -punten. Alle bestaande types (quiz, word-search,
sudoku, bingo) volgen deze vorm.
Let op de volgorde. De lobby groepeert álle niet-bullet-regels bovenaan en toont daarna pas de opsomming, ongeacht waar ze in de tekst staan. Een afsluitende losse zin springt dus naar boven. Zet losse zinnen daarom bewust als intro en houd
--punten voor echte stappen.
bingo: [
'Je speelt bingo. Iedereen krijgt een eigen kaart met getallen.', // intro-zin
'- De begeleider noemt steeds een getal.', // opsomming
'- Staat dat getal op jouw kaart? Tik het aan.',
// ...
].join('\n'),
Wat hoort er wel en niet in. Beschrijf alleen wat de deelnemer zelf doet
en ziet. Host-only mechaniek (het rad bedienen, fasen erkennen, “Resultaten
tonen”) hoort niet in de lobby-uitleg, maar in de eindgebruiker-handleiding
voor begeleiders (apps/frontend/src/app/features/user/whereby/userdocs/index.md).
Een host-instelling die de deelnemer wél merkt, mag er met “kan/kunnen” in (bijv.
“De getrokken getallen kunnen oplichten”), omdat de host die aan of uit zet.
Twee plekken, letterlijk gelijk. Dezelfde tekst staat op de backend
(gameplayService.ts, als snapshot gameDescription op de sessie gezet) én op
de frontend (game-config.types.ts, live gelezen door de lobby). Werk je de
copy bij, doe het dan op beide plekken; ze horen woordelijk identiek te zijn.
De lobby-test leidt de verwachte opsomming dynamisch uit de constante af, dus die
blijft groen zolang het format klopt.
Patronen die je gratis krijgt
Bouw je nieuwe engine bovenop deze conventies, dan loopt de rest mee:
- Recent-gebruikt-vermijden: gebruik
selectPoolIndices. De service past hem al toe viarecordSessionLog. Geen extra werk. - Host-only acties: krijg het
role-argument inapply…Actionen gooiEngineError('INVALID_ACTION', '…')als een deelnemer iets stuurt dat alleen host mag. participantsCanInteract+showLeaderboardleven op de sessie, niet op de gameDefinition.selectGame/setGameTypeseeden de defaults (participantsCanInteract: true,showLeaderboard: false), de host past ze live aan in de lobby, enupdateSessionConfigmerget de partial (quiz forceertparticipantsCanInteract: true). De engine valideertparticipantsCanInteractzelf: een deelnemer-action in een host-only spel →EngineError('INVALID_ACTION', …).- CAS-conflicten worden in de service met retry (3 pogingen) gevangen. Je engine hoeft het niet te weten.
- Presence-grace, panel-toggle, showResults-gating, stop-knop — alle bestaande UX-features werken generiek over speltypes.
- “Speel als deelnemer” (host-avatarbalk): tijdens het spelen toont
GameContainerComponenthost-only deHostActingBarComponentbovenaan het paneel (avatarrij met “Je speelt als jezelf / [naam]”). Klikt de host een deelnemer-avatar, dan zet dat de client-onlyactingAsUserId-signal inGameSocketService(bewust niet in de sessie/broadcast: het is de keuze van één host-client). Jouw spelcomponent reageert er zelf op, op een van twee manieren:- Toeschrijven (woordzoeker, sudoku, quiz): lees
socket.actingAsUserId()en stuur ‘m alsonBehalfOfmee in je action. De engine resolvet via eenresolveActor-helper (host-only + deelnemer-in-sessie) en schrijft de invoer/het antwoord en de score aan die deelnemer toe. Kopieer deonBehalfOf-check uitwordSearchEngine.tsofquizEngine.ts. - Inzien/overnemen (bingo): gebruik
actingAsUserIdals view-switch (viewAsUserId) om de kaart van die deelnemer te tonen i.p.v. het host-scherm; markeren namens die deelnemer gaat ook viaonBehalfOf.
Voeg je een nieuwe host-namens-action toe, geef die dan een optioneel
onBehalfOf?: string(in de actie-typen op backend en frontend) en valideer metresolveActor. De ring om de avatar pakt de vaste spelkleur viacolorForParticipant; je hoeft daar niets voor te doen. - Toeschrijven (woordzoeker, sudoku, quiz): lees
Stappenplan
Doorloop deze stappen ongeveer in volgorde. Halverwege testen
(npm run test:api, npx ng test frontend --no-watch --include "…") is
sneller dan aan het eind alles in één keer.
Backend
1. Types uitbreiden
apps/api/src/realtime/gameplay/gameSessionTypes.ts
- Voeg je type-string toe aan de
GameType-union ('quiz' | 'word-search' | 'sudoku' | '<jouw-type>'). - Voeg een
<JouwGame>State-interface toe (de live state-shape die de engine bewerkt). - Voeg een
<JouwGame>SessionConfig-shape toe (de instellingen die de host in de lobby zet) en hang ‘m aan deGameSessionConfig-union. - Voeg een tak toe aan
GameStateBodymet{ type: '<jouw-type>'; state: <JouwGame>State }. - Voeg per actie een
<JouwGame><Naam>Actiontoe en hang hem in deGameAction-union. Volg de naamconventie<type>:<verb>, bijv.memory:flipCard,memory:resetTurn.
2. Default-instellingen + generieke omschrijving
apps/api/src/realtime/gameplay/gameplayService.ts
- Voeg een
DEFAULT_<JOUW_TYPE>_SESSION_CONFIGtoe en hang ‘m indefaultSessionConfigFor(type). Dit is de basis-sessionConfigwaarmeeselectGame/setGameTypede sessie seedt en die de lobby-instellingenform prefilt. - Voeg een entry toe aan
GAME_TYPE_DESCRIPTIONS(Nederlandse, generieke uitleg per soort — dit is wat deelnemers in de lobby zien, niet een per-definitie tekst). Volg het format uit De lobby-uitleg schrijven. - Heeft je type een eigen sessionConfig-validatie? Schrijf
validate<JouwGame>SessionConfig(cfg). Die wordt aangeroepen vanuitstartSessionenupdateSessionConfig(niet meer vanuit het mongoose-model).
3. Admin-model uitbreiden (alleen pool-gebaseerde types)
Sla deze stap over voor een definitie-loos (gegenereerd) spel — dat heeft geen admin-definitie. Zie Spellen zonder content-pool.
apps/api/src/api/admin-games/gameDefinitionModel.ts
- Voeg je type toe aan
GAME_TYPES. - Definieer het TS-type
<JouwGame>Pool. - Schrijf
validate<JouwGame>Pool(pool)en roep ‘m aan vanuit de bestaande.path('pool').validate(...)-switch. Let opresolveTypeInValidator— die heeft zowel doc-context als query-context (findByIdAndUpdate) nodig. sessionConfigis op het model optioneel (Schema.Types.Mixed, geen path-validator meer). Het beheer zet ‘m niet; de instellingen leven in de lobby. Je hoeft hier dus niets voorsessionConfigte doen.
apps/api/src/api/admin-games/gameDefinitionSchemaValidators.ts
- Voeg een tak toe aan
z.discriminatedUnion('type', [...])met enkeltype,title,isActive, jepoolen de gedeeldeTenantLinkFields. GeensessionConfig, vlaggen of beschrijving — die horen niet meer in het create/update-contract.
4. Engine schrijven
Maak apps/api/src/realtime/gameplay/<jouwGame>Engine.ts. Kopieer
wordSearchEngine.ts als template, niet quizEngine.ts (die heeft een
complexere multi-fase flow).
Exporteer minimaal twee functies:
export function buildInitial<JouwGame>State(args: {
pool: <JouwGame>Pool;
sessionConfig: <JouwGame>SessionConfig;
recentlyUsed: Iterable<number>;
random?: () => number;
}): <JouwGame>State;
export function apply<JouwGame>Action(args: {
session: GameSession;
action: GameAction;
userId: string;
role: ParticipantRole;
}): { state: <JouwGame>State; finished: boolean };
Regels voor de engine:
- Pure functies. Geen imports van
gameSessionStore, geensocket-objecten, geen mongoose. Random injecteerbaar voor tests. - Recent-gebruikt: gebruik
selectPoolIndicesuitpoolSelection.ts(sla over bij een definitie-loos spel). EngineErrorkomt uitquizEngine.ts— importeer ‘m, gooi ‘m bij ongeldige actie-data, verkeerde rol, ongeldige state-overgang. Codes:INVALID_ACTION,WRONG_GAME_STATE.- Idempotency: een dubbele actie (race tussen deelnemers) mag geen fout zijn — return de huidige state.
5. Service-switch aanpassen
apps/api/src/realtime/gameplay/gameplayService.ts
- Voeg een tak toe aan
buildInitialBody(gameDef, recentlyUsed). - Voeg een tak toe aan
applyAction’sbody.type-switch (waar nuquiz,word-searchensudokugehandeld worden).
Verder hoef je in de service niks aan host-check, CAS, broadcast of session-log te doen — dat is generiek.
6. Backend-tests
Schrijf engine-tests volgens het patroon van
apps/api/src/realtime/gameplay/__tests__/wordSearchEngine.test.ts:
- Mulberry32 voor deterministische randomness.
- Build-state, apply happy-path, edge-cases.
- Per nieuwe action: rol-check, validatie, idempotency, finished-overgang.
Voor de service hoef je geen integratietests te kopiëren; de bestaande service-tests draaien tegen ioredis-mock en hebben quiz-flow als smoke-test.
Frontend — gedeelde types
apps/frontend/src/app/shared/services/games/game-config.types.ts
- Mirror je nieuwe type, pool en sessionConfig.
- Voeg een entry toe aan
GAME_TYPE_LABELS(Nederlandstalig) en aanGAME_TYPE_DESCRIPTIONS(generieke lobby-uitleg, woordelijk gelijk aan de backend; zie De lobby-uitleg schrijven).
apps/frontend/src/app/shared/services/games/game-session.types.ts
- Mirror
<JouwGame>State, deGameStateBody-tak en de action-typen. Houd de shapes letterlijk gelijk aan de backend (FE is de naïeve consumer; geen eigen normalisering).
Frontend — admin-builder (alleen pool-gebaseerde types)
Sla deze sectie over voor een definitie-loos spel.
apps/frontend/src/app/features/admin/games/games-admin.types.ts
- Voeg je
<JouwGame>GameDefinitiontoe en neem ‘m op inGameDefinitionInput. De definitie draagt alleenpool(+ titel/isActive/tenants);sessionConfigis optioneel en wordt door het beheer niet gezet.
apps/frontend/src/app/features/admin/games/pages/game-editor/<jouw-type>-form.component.ts
- Maak een content-only standalone component met een Reactive
FormGroupvoor de pool (vragen/woorden/…). ExposeerformGroup(voor validity) engetValue(): { pool }. Volg de stijl vanquiz-form.component.tsofword-search-form.component.ts— die bevatten sinds de afslanking géén instellingen meer.
game-editor.component.ts
- Voeg je type toe aan
typeOptions. - Voeg een
@ViewChild+ een@switch-tak toe voor het juiste sub-form, en haakgetValue()in de save-payload (zie hoequizenword-searchdecommon-payload{ title, isActive, runForAllTenants, tenants }aanvullen met...getValue()).
Tests: kopieer de structuur van bestaande content-only *-form.component.spec.ts.
Frontend — lobby-selectie + instellingen
apps/frontend/src/app/shared/components/games/settings-forms/<jouw-type>-settings-form.component.ts
- Maak een settings-form analoog aan
quiz-settings-form.component.ts/word-search-settings-form.component.ts. Input: de huidigeconfig(+ eventueelavailableCountbij pool-types). Output:(configChange)met de nieuwesessionConfig. De lobby debounced dit naarsocket.updateConfig(activityId, { sessionConfig }).
apps/frontend/src/app/shared/components/games/game-lobby/game-lobby.component.{ts,html}
- Voeg een
@switch (gameType)-tak toe die jouw settings-form rendert in hetshowConfigureAndStart-blok. - Is je type pool-gebaseerd? Dan verschijnt het automatisch in de
definitie-picker (gevoed door de catalogus). Is het definitie-loos? Zorg dan
dat
GameTypeSelectorComponenthet altijd als kiesbaar soort toont (zoals sudoku), en dat de lobby direct naar de instellingen + start gaat.
apps/frontend/src/app/shared/components/games/.../game-type-selector.component.ts
- De beschikbare soorten worden afgeleid uit de catalogus (pool-types) plus de altijd-aanwezige definitie-loze types. Voeg je type op de juiste plek toe.
Frontend — gameplay-component
apps/frontend/src/app/shared/components/games/<jouw-type>-game/
- Maak een standalone component analoog aan
WordSearchGameComponent. - Verplichte inputs:
session: GameSession,activityId: string,viewerUserId: string. - Wrap
sessionin eensignalzoals de woordzoeker doet, zodat computed signals reactief blijven bij elkegame:stateUpdate. - Stuur acties via
socket.sendAction(activityId, { type: '<type>:<verb>', data: {…} }). - Voor host-only knoppen (stoppen, resultaten tonen): kopieer de
patronen uit
WordSearchGameComponent(endGame()metConfirmDialogComponent,showResults()met disabled-state).
apps/frontend/src/app/shared/components/games/game-container/game-container.component.{ts,html}
- Importeer het nieuwe component.
- Voeg een tak toe aan de
@switch (session.gameType). - Als je nieuwe speltype zelf een stop-knop heeft (zoals word-search),
voeg een tak toe aan
showStopButton’s gameType-filter zodat de container niet ook nog z’n eigen knop toont.
Tests: kopieer WordSearchGameComponent.spec.ts-patronen. Mock
GameSocketService en MatDialog als je host-acties test.
Frontend — tenant-facing catalogus-type
De backend-catalogus (GET /games) is type-agnostisch, maar de frontend
tenant-laag heeft een eigen, smallere GameType die je voor een
pool-gebaseerd type moet bijwerken — anders mist je type in de typing van de
catalogus:
apps/frontend/src/app/shared/services/games/games.service.ts— voeg je type toe aanGameType(typeert de/games-catalogusrespons).
De card-koppeling is sinds de lobby-herinrichting een pure
gamesEnabled- toggle (geen per-card spel-keuze meer). Er is dus geen koppel-dropdown of per-type card-summary meer om bij te werken.
Overige type-switches (per-type weergave)
Deze plekken switchen op het speltype voor weergave. Een nieuw type werkt
vaak al via een @default/fallback, maar voor nette UI voeg je een tak toe:
game-finished.component.{ts,html}— per-type eindstand (sudoku toont bijvoorbeeld het aantal door elke deelnemer ingevulde cellen).games-list.component.ts—typeOptions(het type-filter in de admin- lijst) énpoolSummary(de “Pool”-kolom). Alleen relevant voor pool-gebaseerde types; definitie-loze types staan niet in de admin-lijst.game-playing-placeholder.component.ts—typeLabel(het label tijdens de aftel-countdown vóór het spel verschijnt).
Wat niet hoeft
- Geen nieuwe socket-events. Selectie loopt via de generieke
game:setGameType/game:selectGame/game:updateConfig; spelacties viagame:action. - Geen nieuwe Redis-keys of CAS-logica. De store is generiek.
- Geen wijziging in
presenceScheduler,setPanelOpen,stopSession,leaveSession, host-check. Die werken per definitie generiek. - Geen backend-wijziging in
cardService/activityRouter:/game. De card-koppeling is een type-agnostischegamesEnabled-toggle. - Geen wijziging in de selectie-flow van
GameLobbyComponentzelf — je voegt alleen een settings-form-tak toe.GameFinishedComponentkan een per-type eindstand-tak krijgen (zie Overige type-switches); zonder tak toont ‘ie voor jouw type simpelweg niets.
Tests draaien
Backend:
npm run test:api
Of gericht:
cd apps/api && npx vitest run src/realtime/gameplay
cd apps/api && npx vitest run src/api/admin-games
Frontend:
npm run test:frontend:ci -- --include "apps/frontend/src/app/shared/components/games/<jouw-type>-game/**" \
--include "apps/frontend/src/app/shared/components/games/settings-forms/**" \
--include "apps/frontend/src/app/features/admin/games/**"
Aanbevolen volgorde: engine-tests → service-tests → admin-form-tests → settings-form-tests → gameplay-component-tests. Elke laag op zich groen voordat je de volgende opent. Anders raak je het overzicht kwijt waarom iets faalt.
Veelgemaakte fouten
GameType-union vergeten uit te breiden. TypeScript klaagt expliciet, maar mensen comment denever-branch weg om “verder” te kunnen. Niet doen — die branch is de catch-all-defense.- Engine die
gameSessionStoreimporteert. Direct rood vlaggetje. Engines moeten puur blijven; de service handelt persistence. - Engine die auto-finished returnt op “laatste vondst”-actie. Werkt
technisch, maar de UX-conventie in deze codebase is dat de host
expliciet doorklikt (
showResults-actie). ZiewordSearchEngine.tsvoor de juiste vorm. participantsCanInteractchecken in de handler. Doe dat in de service of de engine — de handler blijft dun.- Instellingen in de gameDefinition willen stoppen. De definitie draagt
alleen content (pool). Instellingen (
sessionConfig+ de vlaggen) leven op de sessie en worden in de lobby gezet. Een nieuwe instelling hoort dus in de<type>SessionConfig+ de settings-form, niet in het admin-model. defaultSessionConfigFor/GAME_TYPE_DESCRIPTIONSvergeten. Zonder een default-config seedtselectGameniets bruikbaars; zonder omschrijving toont de lobby een lege uitleg.- De lobby-uitleg op één plek bijwerken.
GAME_TYPE_DESCRIPTIONSbestaat dubbel (backendgameplayService.ts+ frontendgame-config.types.ts) en hoort woordelijk gelijk te zijn. Pas je er één aan, dan drift de copy: de lobby leest de frontend-versie live, maar de op de sessie opgeslagen snapshot blijft de oude backend-tekst. - Frontend computed signals die
sessionlezen via@Inputzonder signal-wrap. Resultaat: één keer cachet, lateregame:stateUpdates komen niet door. Wrap je input in eensignal<T | null>(null)en delegeer. - Mongoose pool-validator vergeten te updaten. De Zod-validator vangt
request-input, maar
findByIdAndUpdatetriggert ook mongoose-validators (runValidators: truestaat aan) — zonder branch voor je type faalt admin-update stilzwijgend. - Cells/knoppen die
[disabled]zijn maar nog wel focus stelen van Whereby. Voor read-only deelnemers:pointer-events: noneop de disabled state. Zieword-search-game.component.scss. - “Mijn nieuwe spel verschijnt niet in de lobby.” Voor een
pool-gebaseerd type is dit bijna nooit een code-bug: een nieuw aangemaakt
spel staat default op
runForAllTenants: falsemet een legetenants-toegangslijst en is dus voor géén enkele tenant zichtbaar. Koppel het in/admin/spellenaan een tenant (of zet “Alle tenants” aan) en zorg datisActiveaanstaat — deGameTypeSelectorComponentleidt de beschikbare soorten af uit de catalogus. Voor een definitie-loos type: check dat de selector het type als altijd-beschikbaar toont.
Pointers
| Wat | Waar |
|---|---|
| Bestaande types als referentie | apps/api/src/realtime/gameplay/quizEngine.ts, wordSearchEngine.ts |
| Inhoudsloos (gegenereerd, definitie-loos) spel als referentie | apps/api/src/realtime/gameplay/sudokuEngine.ts |
| Gedeelde host-gestuurde state + per-deelnemer state als referentie | apps/api/src/realtime/gameplay/bingoEngine.ts |
| Service-switches + defaults + omschrijvingen | gameplayService.ts — buildInitialBody, applyAction, defaultSessionConfigFor, GAME_TYPE_DESCRIPTIONS |
| Selectie + instellingen op de sessie | gameplayService.ts — selectGame, setGameType, updateSessionConfig, startSession |
| Socket-handlers | gameplayHandlers.ts |
| Session-types | gameSessionTypes.ts |
| CAS-store + retry-patroon | gameSessionStore.ts, gameplayService.joinSession |
| Recent-gebruikt | poolSelection.ts, gameSessionLogService |
| Admin-model + validators (content-only) | gameDefinitionModel.ts, gameDefinitionSchemaValidators.ts |
| Card-koppeling (toggle) | cardModel.ts (gamesEnabled) |
| Activity-naar-spel-lookup | activityRouter.ts — GET /activities/:id/game (geeft { gamesEnabled, cardName }) |
| Admin-form-patroon (content-only) | apps/frontend/src/app/features/admin/games/pages/game-editor/ |
| Lobby-selectie + instellingen | shared/components/games/game-lobby/, shared/components/games/settings-forms/, game-type-selector.component |
| Container-switch | apps/frontend/src/app/shared/components/games/game-container/ |
| Stop / showResults / onBehalfOf-send | word-search-game.component.{ts,html,scss} |
“Speel als deelnemer”-balk (avatarrij + actingAsUserId) |
shared/components/games/host-acting-bar/, game-socket.service.ts (actingAsUserId, setActingAs), game-avatar.service.ts |
| Gedeelde FE-types + labels/omschrijvingen | shared/services/games/game-config.types.ts, game-session.types.ts, games.service.ts |
| Per-type weergave (eindstand / lijst / placeholder) | game-finished.component.{ts,html}, games-list.component.ts, game-playing-placeholder.component.ts |
Het meeste werk is mechanisch — uitbreiding van bestaande switches en unions. Het kerndenkwerk gaat in de engine: welke state-shape, welke acties, welke validaties. Houd die los van de rest van het systeem en de aansluiting met service/handlers/lobby/frontend volgt vanzelf.