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:
    • quiz en word-search trekken hun inhoud uit een redactie-pool die een admin in /admin/spellen beheert.
    • sudoku is 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.
    • bingo is óók inhoudsloos, maar het voorbeeld van een spel met gedeelde, host-gestuurde state (de host trekt nummers met bingo:draw) náást per-deelnemer state (iedere deelnemer krijgt lazy een eigen kaart via bingo:requestCard en 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 vlaggen participantsCanInteract / showLeaderboard). De gameDefinition levert geen instellingen aan.
  • Sessie is Redis met optimistic concurrency (zie apps/api/src/realtime/gameplay/gameSessionStore.ts). Elke schrijfactie bumpt een version-veld; bij conflict gooit de store VersionConflictError. Callers retryen door opnieuw te load-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:

  1. Initial state — bij game:start bouwt de engine een verse state uit de pool + sessionConfig. Hier sluit je aan op selectPoolIndices() uit poolSelection.ts zodat recent gebruikte items in dezelfde card-context worden vermeden. De sessionConfig komt van de sessie (door de host in de lobby gezet), met defaultSessionConfigFor(type) als fallback.
  2. Action-handling — bij game:action bewerkt de engine de bestaande state op basis van een action-payload. Returnt { state, finished: boolean }. finished: true schakelt de sessie naar status: '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 hoe word-search dat 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 in gameDefinitionSchemaValidators.ts en 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; selectGame wordt niet gebruikt.
  • In de engine: een lege pool ({}), usedItems: [] in de state en geen selectPoolIndices-aanroep (elke ronde is uniek), alle inhoud-logica in buildInitial…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 via recordSessionLog. Geen extra werk.
  • Host-only acties: krijg het role-argument in apply…Action en gooi EngineError('INVALID_ACTION', '…') als een deelnemer iets stuurt dat alleen host mag.
  • participantsCanInteract + showLeaderboard leven op de sessie, niet op de gameDefinition. selectGame/setGameType seeden de defaults (participantsCanInteract: true, showLeaderboard: false), de host past ze live aan in de lobby, en updateSessionConfig merget de partial (quiz forceert participantsCanInteract: true). De engine valideert participantsCanInteract zelf: 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 GameContainerComponent host-only de HostActingBarComponent bovenaan het paneel (avatarrij met “Je speelt als jezelf / [naam]”). Klikt de host een deelnemer-avatar, dan zet dat de client-only actingAsUserId-signal in GameSocketService (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 als onBehalfOf mee in je action. De engine resolvet via een resolveActor-helper (host-only + deelnemer-in-sessie) en schrijft de invoer/het antwoord en de score aan die deelnemer toe. Kopieer de onBehalfOf-check uit wordSearchEngine.ts of quizEngine.ts.
    • Inzien/overnemen (bingo): gebruik actingAsUserId als view-switch (viewAsUserId) om de kaart van die deelnemer te tonen i.p.v. het host-scherm; markeren namens die deelnemer gaat ook via onBehalfOf.

    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 met resolveActor. De ring om de avatar pakt de vaste spelkleur via colorForParticipant; je hoeft daar niets voor te doen.


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 de GameSessionConfig-union.
  • Voeg een tak toe aan GameStateBody met { type: '<jouw-type>'; state: <JouwGame>State }.
  • Voeg per actie een <JouwGame><Naam>Action toe en hang hem in de GameAction-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_CONFIG toe en hang ‘m in defaultSessionConfigFor(type). Dit is de basis-sessionConfig waarmee selectGame/setGameType de 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 vanuit startSession en updateSessionConfig (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 op resolveTypeInValidator — die heeft zowel doc-context als query-context (findByIdAndUpdate) nodig.
  • sessionConfig is 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 voor sessionConfig te doen.

apps/api/src/api/admin-games/gameDefinitionSchemaValidators.ts

  • Voeg een tak toe aan z.discriminatedUnion('type', [...]) met enkel type, title, isActive, je pool en de gedeelde TenantLinkFields. Geen sessionConfig, 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, geen socket-objecten, geen mongoose. Random injecteerbaar voor tests.
  • Recent-gebruikt: gebruik selectPoolIndices uit poolSelection.ts (sla over bij een definitie-loos spel).
  • EngineError komt uit quizEngine.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’s body.type-switch (waar nu quiz, word-search en sudoku gehandeld 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 aan GAME_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, de GameStateBody-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>GameDefinition toe en neem ‘m op in GameDefinitionInput. De definitie draagt alleen pool (+ titel/isActive/tenants); sessionConfig is 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 FormGroup voor de pool (vragen/woorden/…). Exposeer formGroup (voor validity) en getValue(): { pool }. Volg de stijl van quiz-form.component.ts of word-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 haak getValue() in de save-payload (zie hoe quiz en word-search de common-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 huidige config (+ eventueel availableCount bij pool-types). Output: (configChange) met de nieuwe sessionConfig. De lobby debounced dit naar socket.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 het showConfigureAndStart-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 GameTypeSelectorComponent het 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 session in een signal zoals de woordzoeker doet, zodat computed signals reactief blijven bij elke game:stateUpdate.
  • Stuur acties via socket.sendAction(activityId, { type: '<type>:<verb>', data: {…} }).
  • Voor host-only knoppen (stoppen, resultaten tonen): kopieer de patronen uit WordSearchGameComponent (endGame() met ConfirmDialogComponent, 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 aan GameType (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.tstypeOptions (het type-filter in de admin- lijst) én poolSummary (de “Pool”-kolom). Alleen relevant voor pool-gebaseerde types; definitie-loze types staan niet in de admin-lijst.
  • game-playing-placeholder.component.tstypeLabel (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 via game: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-agnostische gamesEnabled-toggle.
  • Geen wijziging in de selectie-flow van GameLobbyComponent zelf — je voegt alleen een settings-form-tak toe. GameFinishedComponent kan 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 de never-branch weg om “verder” te kunnen. Niet doen — die branch is de catch-all-defense.
  • Engine die gameSessionStore importeert. 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). Zie wordSearchEngine.ts voor de juiste vorm.
  • participantsCanInteract checken 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_DESCRIPTIONS vergeten. Zonder een default-config seedt selectGame niets bruikbaars; zonder omschrijving toont de lobby een lege uitleg.
  • De lobby-uitleg op één plek bijwerken. GAME_TYPE_DESCRIPTIONS bestaat dubbel (backend gameplayService.ts + frontend game-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 session lezen via @Input zonder signal-wrap. Resultaat: één keer cachet, latere game:stateUpdates komen niet door. Wrap je input in een signal<T | null>(null) en delegeer.
  • Mongoose pool-validator vergeten te updaten. De Zod-validator vangt request-input, maar findByIdAndUpdate triggert ook mongoose-validators (runValidators: true staat 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: none op de disabled state. Zie word-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: false met een lege tenants-toegangslijst en is dus voor géén enkele tenant zichtbaar. Koppel het in /admin/spellen aan een tenant (of zet “Alle tenants” aan) en zorg dat isActive aanstaat — de GameTypeSelectorComponent leidt 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.tsbuildInitialBody, applyAction, defaultSessionConfigFor, GAME_TYPE_DESCRIPTIONS
Selectie + instellingen op de sessie gameplayService.tsselectGame, 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.tsGET /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.