Build a game screen
Goal
Show a menu with a Play button, then a 2×2 answer grid that scores a correct click. Drag one answer card onto a slot to earn another point.
Files you will edit
src/game.tstests/game.test.ts— the optional headless check.
Steps
Start in a scaffolded game directory. This screen needs no assets or host edits.
In
src/game.ts, replace the game's imports and add this screen state and builder. The panel is 320px wide and each answer button is at least 44px tall. Build strings and objects only when state changes.tsimport { defineGame, type UiNode } from 'gameable'; let playing = false; let score = 0; let answered = false; let placed = false; let dirty = true; let screen: UiNode; function buildScreen(): UiNode { if (!playing) { return { type: 'panel', id: 'screen', anchor: 'center', modal: true, width: 320, padding: 16, title: 'Tiny trivia', children: [{ type: 'button', id: 'play', text: 'Play', variant: 'primary', height: 44 }], }; } return { type: 'panel', id: 'screen', anchor: 'center', modal: true, width: 320, padding: 16, gap: 10, title: 'Tiny trivia', children: [ { type: 'text', id: 'score', text: `Score: ${score}` }, { type: 'text', text: 'What is 2 + 2?' }, { type: 'grid', columns: 2, gap: 10, children: [ { type: 'button', id: 'answer-3', text: '3', height: 44, disabled: answered }, { type: 'button', id: 'answer-4', text: '4', height: 44, disabled: answered }, { type: 'button', id: 'answer-5', text: '5', height: 44, disabled: answered }, { type: 'button', id: 'answer-6', text: '6', height: 44, disabled: answered }, ], }, { type: 'stack', id: 'hand', children: placed ? [] : [ { type: 'card', id: 'four-card', title: '4', body: 'Drag me to the slot', draggable: true, height: 60, }, ], }, { type: 'panel', id: 'slot', title: 'Answer slot', dropTarget: true, children: placed ? [{ type: 'card', id: 'four-card', title: '4', body: 'Placed!', height: 60 }] : [{ type: 'text', text: 'Drop the 4 here' }], }, ], }; }Replace the default export in the same file with this definition. Events change state before the dirty model is rebuilt. The card keeps its id when it moves from the hand into the slot. A wrong answer locks the grid without adding points; the card can score once independently.
tsexport default defineGame({ init() { playing = false; score = 0; answered = false; placed = false; dirty = true; screen = buildScreen(); }, systems: [ { on: 'client', run(ctx) { if (!playing && ctx.ui.clicked('play')) { playing = true; dirty = true; } else if (playing) { if ( !answered && (ctx.ui.clicked('answer-3') || ctx.ui.clicked('answer-4') || ctx.ui.clicked('answer-5') || ctx.ui.clicked('answer-6')) ) { if (ctx.ui.clicked('answer-4')) score += 1; answered = true; dirty = true; } const drop = ctx.ui.dropped('four-card'); if (!placed && drop?.target === 'slot') { placed = true; score += 1; dirty = true; } } if (dirty) { screen = buildScreen(); dirty = false; } ctx.ui.set(screen); }, }, ], });Together the two snippets form
src/game.ts. Keepscreenbetween ticks; do not putbuildScreen()directly insidectx.ui.set. This example keeps score on the page. For a shared room score, send the action to the authority withctx.net.sendand draw its reply; a client cannot change shared state.
Verify
From your game directory:
sh
npm run devYou should see a centred menu and a mint Play button. Click Play to see four answers in two columns and Score: 0. Click 4: the score becomes 1 and all answers disable. Drag the 4 card onto Answer slot: it moves into the slot and the score becomes 2. Dropping elsewhere must not score. At a 375px viewport, the panel and both columns should stay on screen. Repeat the drag with touch.
For a headless check, put this in tests/game.test.ts. It uses the FPS template's createGuest, createMockHost, createGameConfig and createFrameInput pattern, with the public gameable and gameable/testing imports.
ts
import { createGuest } from 'gameable';
import { createFrameInput, createGameConfig, createMockHost } from 'gameable/testing';
import { expect, it } from 'vitest';
import game from '../src/game';
it('opens the grid, scores a click and drop, then emits nothing unchanged', () => {
const guest = createGuest(createMockHost(), game);
guest.init(createGameConfig());
const menu = guest.tick(createFrameInput({ frame: 0 }));
expect(menu.ui).toContain('"id":"play"');
const frame = createFrameInput({ frame: 1 });
frame.events.push({
tag: 'ui',
val: { kind: 'click', id: 'play', target: '', value: '', x: 0, y: 0 },
});
const grid = guest.tick(frame);
expect(grid.ui).toContain('"columns":2');
expect(grid.ui).toContain('Score: 0');
expect(grid.ui).not.toContain('"id":"play"');
const answer = createFrameInput({ frame: 2 });
answer.events.push({
tag: 'ui',
val: { kind: 'click', id: 'answer-4', target: '', value: '', x: 0, y: 0 },
});
expect(guest.tick(answer).ui).toContain('Score: 1');
const drop = createFrameInput({ frame: 3 });
drop.events.push({
tag: 'ui',
val: { kind: 'drop', id: 'four-card', target: 'slot', value: '', x: 100, y: 200 },
});
const placed = guest.tick(drop);
expect(placed.ui).toContain('Score: 2');
expect(placed.ui).toContain('Placed!');
expect(guest.tick(createFrameInput({ frame: 4 })).ui).toBeUndefined();
});Run it with your scaffold's npm test command. This checks guest behavior without a browser; the visual and touch checks above exercise the host DOM.
See also
- Game screens and UI
- Add a HUD element
- Play with friends — room authority and clients.
- Trivia example:
examples/trivia/src/game.ts— a complete game screen.