Skip to content

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.ts
  • tests/game.test.ts — the optional headless check.

Steps ​

Start in a scaffolded game directory. This screen needs no assets or host edits.

  1. 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.

    ts
    import { 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' }],
          },
        ],
      };
    }
  2. 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.

    ts
    export 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. Keep screen between ticks; do not put buildScreen() directly inside ctx.ui.set. This example keeps score on the page. For a shared room score, send the action to the authority with ctx.net.send and draw its reply; a client cannot change shared state.

Verify ​

From your game directory:

sh
npm run dev

You 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 ​