跳到正文
DEV Community· Enrique Lazo Bello·· 5 小時前AI 評分49

Matemáticas con Dados:如何製作骰子與瓶蓋計數遊戲,協助女兒學習數數

Matemáticas con Dados: how I built a game to help my little girl learn to count with dice and real bottle caps

AI 導讀

作者逐步示範如何用 HTML、CSS 和 JavaScript,從零製作一款讓幼兒配合實體瓶蓋學習數數的網頁遊戲,全程不使用框架。遊戲包含 Count and Add、西班牙語語音、答錯時的引導,以及結束畫面,並以平靜、可預測的互動配合孩子的學習節奏。

正文

A web game made with HTML, CSS and JavaScript, no framework, designed for little kids. In this tutorial we build it from scratch: the idea, the research behind every decision, and the code, step by step.

Who it's for: programmers who are just starting out and programming enthusiasts with basic HTML and JavaScript knowledge. If you know what a function and an array are, you can follow along.

What you'll build: a minimal but complete version of the game, with Count and Add, a Spanish voice, help when the child gets it wrong, and a closing screen.

Full code: https://github.com/the-bunker-code/matematicas-con-dados
Let's play: https://the-bunker-code.github.io/matematicas-con-dados/

A note on language. The game is in Spanish: it was made for a Spanish-speaking child, and the voice speaks Spanish. In the code below, the comments are translated to English, but identifiers and on-screen strings stay exactly as they are in the repository, so you can match them. A few words you'll see often: contar = count, sumar = add, tapita = bottle cap, "2 más 1 es igual a…" = "2 plus 1 equals…", "¿Cuántas hay?" = "How many are there?", "Hay cuatro." = "There are four.", "Dos más y guardamos" = "Two more and we put them away". Want it to speak English? Step 8 explains what to change.

Table of contents

  1. The idea
  2. The research: what a child needs to learn to count
  3. Technical decisions: why no framework
  4. Step 0: folders and a local server
  5. Step 1: the HTML for the three screens
  6. Step 2: configuration
  7. Step 3: the game rules (pure logic)
  8. Step 4: test the rules before there is a screen
  9. Step 5: state and actions
  10. Step 6: drawing the screen
  11. Step 7: wiring up the clicks
  12. Step 8: voice and sounds without audio files
  13. Step 9: styles
  14. From prototype to real project
  15. Publishing it
  16. What I learned
  17. Glossary
  18. Appendix: the full CSS of the minimal version

1. The idea

It all started on a small children's table, with a white box full of plastic bottle caps and a phone resting beside it.

At almost 4 years old, my little girl learns with her hands: she needs to feel, move, stack and sort before an abstract symbol makes sense. Traditional math apps did exactly the opposite: they filled the screen with flashing lights, harsh noises when she made a mistake, and empty rewards that pulled her into the glass and disconnected her from what was around her.

I decided to flip the interaction around: the app doesn't replace the bottle caps, it accompanies them.

The phone, resting on the table, poses the challenge silently or with a calm voice. She looks at the two dice on the screen: a 4 and a 3. She reaches into the box, takes out four red caps and three green ones, lines them up in rows and counts them out loud, touching each one with her finger. Only after she has felt the quantity in her hands does she go back to the screen and pick the result. Next to her there is always an adult who doesn't interrupt; they just go along at her pace.

These rules were born from watching her play at that table, and the code follows them to the letter:

  • No punishment and no loud noises. There is never a sharp "no" or an error sound. If she gets it wrong, the app counts along with her and shows her the way.
  • The screen is a board, not a distraction. The phone stays still on the table. The graphics are simple and stable, so attention stays on the caps.
  • Groups you can touch. Each die has its own color, and each color is a group of caps. That way, adding means joining two real groups.
  • Short, predictable sessions. She picks 3, 5 or 8 rounds. A warning plays before the end, so the ending doesn't catch her by surprise.
  • The end feels like the end. The button to play again takes 4 seconds to appear. That pause keeps her from tapping on and on.
  • It respects what's on the table. The game never asks for more caps than fit in her box.
  • Visual calm. There are very few animations (none if the phone has reduced motion turned on), each operation always has the same color, and the phrases are short, in neutral Spanish.

2. The research: what a child needs to learn to count

Before writing code, it pays to understand what we're teaching. You don't need to be a specialist; a few concepts that early math education has used for decades are enough.

Note: this section summarizes general concepts to explain the design decisions. It is not a clinical guide. If you work with a child with specific needs, the word of their therapist and their family comes before any app.

Counting is not reciting

A child can say "one, two, three, four" from memory and still not know how to count. In 1978, psychologists Rochel Gelman and C. R. Gallistel described the principles behind real counting. Three of them guide this game:

Principle What it means How the game applies it
One-to-one correspondence Each object is counted exactly once. Each tapped cap shows its number and, if tapped again, repeats the same number.
Stable order Numbers are always said in the same order. The voice says "uno, dos, tres…" with each tap.
Cardinality The last number said is how many there are. When the help ends, the voice says "Hay cuatro." ("There are four.") and the correct answer starts pulsing.

From concrete to abstract

A widely used approach to teaching math is concrete → pictorial → abstract: first real objects, then drawings of those objects, and only at the end the symbols (2 + 1 = 3). The game has all three on the same screen:

  • Concrete: the real caps on the table ("Tapitas reales", real caps mode).
  • Pictorial: the drawn caps and the pips on the dice.
  • Abstract: the phrase "2 más 1 es igual a…" and the numbers on the answers.

Seeing quantities without counting

Adults see three dots and know there are three without counting them. That ability is called subitizing, and it works with small quantities. That's why:

  • The dice are drawn with their usual pips, not with numbers.
  • Under each answer there are as many little dots as its value.
  • The caps are arranged in rows of 5, like an egg carton: "one full row and two more" is easier to see than a messy pile.

Predictability and transitions

For many children, and especially for many autistic children, the unexpected is hard: the abrupt end of an activity, a loud sound, a change of colors. Several rules come from that:

  • Visual anchor: each operation has its fixed color (Count blue, Add orange…) and the mascot is always the same red cap.
  • Announce the end: two rounds before finishing, the voice says "Dos más y guardamos" ("Two more and we put them away"), and on the last one, "La última y guardamos" ("The last one and we put them away").
  • Closing: the final screen says "Ahora guardamos las tapitas juntos" ("Now we put the caps away together") and the buttons appear after 4 seconds. That way the ending doesn't turn into "one more, one more".

Attention and mistakes

With ADHD, every stimulus competes for attention. That's why the game has few animations, no distracting prizes, and a mistake is not punished: a soft, low tone plays, the app counts out loud with the child, and the correct answer pulses. The mistake becomes a chance to count together.

The adult is part of the game

The game is designed to be played with an adult nearby. On screen there is a "Para el adulto" (For the adult) section with tips ("Count first, slowly…", "If they pick wrong, don't say «no»…") and a mode where the screen only shows the dice and tells the adult what to take out of the box.

3. Technical decisions: why no framework

The game has 3 screens and at most about 60 caps on screen. For that you don't need React, Vue or a bundler. Using "plain" HTML, CSS and JavaScript has real advantages:

  • What you publish is exactly the files you wrote. There is no build step that can break.
  • Anyone who is just starting out can read the code from top to bottom.
  • Modern browsers already ship everything we need: modules (import/export), voice (Web Speech API), sound (Web Audio API) and storage (localStorage).

The architecture in one picture

   child's click
        │
        ▼
   main.js ── finds the button's data-action ──▶ game.js (action)
                                                    │
                                     changes `state` and calls notify()
                                                    │
                                                    ▼
                                    render.js draws the screen

Each file has a single responsibility:

File What it does What it does NOT do
rules.js Takes settings and returns a round. Doesn't touch the DOM, no sound.
game.js Holds the state and has the actions. Doesn't build HTML.
render.js Reads the state and draws. Doesn't change the state.
main.js Listens for clicks and calls the actions. Has no game logic.

The most important rule: information flows in one direction only. If something looks wrong, the problem is in render.js. If the game does something it shouldn't, it's in game.js or rules.js. It's the same idea React or Redux use, but without the library.

Step 0: folders and a local server

Create this structure:

matematicas-con-dados/
├── index.html
├── css/
│   └── styles.css
├── js/
│   ├── audio.js      voice and sounds
│   ├── config.js     fixed data: colors, timings
│   ├── game.js       state and actions
│   ├── main.js       entry point
│   ├── render.js     draws the screen
│   ├── rules.js      rules: builds each round
│   └── spanish.js    numbers as words
├── tests/
│   └── rules.test.js
└── package.json

In package.json we only need to tell Node that we use modules:

{ "name": "matematicas-con-dados", "type": "module", "private": true }

Why double-clicking the file isn't enough

We're going to use JavaScript modules (<script type="module">). For security reasons, browsers don't load modules from file://. You need a local server. Any of these works:

python3 -m http.server 3000      # if you have Python

or the Live Server extension for VS Code. Then open http://localhost:3000.

The real project has a small Node server (scripts/serve.js) that also applies the same security headers as the published site. You don't need it for this tutorial.

Step 1: the HTML for the three screens

The game has three screens: home, game and end. All three live in the same HTML and are shown or hidden with the hidden attribute. The zones that change (tiles, dice, table, answers) start empty: JavaScript fills them in.

index.html:

<!doctype html>
<html lang="es">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Matemáticas con Dados</title>
    <link rel="stylesheet" href="css/styles.css" />
    <script type="module" src="js/main.js"></script>
  </head>
  <body>
    <main class="app">
      <!-- HOME -->
      <section id="screen-home" class="screen">
        <h1>Matemáticas con Dados</h1>
        <div id="operation-tiles" class="tiles"></div>
      </section>

      <!-- GAME -->
      <section id="screen-game" class="screen" hidden>
        <button type="button" id="prompt" class="prompt" data-action="repeat-prompt"></button>
        <div id="dice-row" class="dice-row"></div>
        <div id="table" class="table"></div>
        <div id="answers" class="answers"></div>
      </section>

      <!-- END -->
      <section id="screen-end" class="screen" hidden>
        <h2>Terminamos</h2>
        <p>Ahora guardamos las tapitas juntos.</p>
        <div id="end-buttons" class="end-buttons" hidden>
          <button type="button" data-action="go-home">Inicio</button>
          <button type="button" data-action="play-again">Otra vez</button>
        </div>
      </section>
    </main>
  </body>
</html>

Three details:

  • lang="es" makes screen readers read the page in Spanish.
  • type="module" lets us use import/export and runs the script once the HTML has loaded. No need to wait for DOMContentLoaded.
  • No button has an onclick. They carry data-action="..." and, in step 7, a single listener handles all of them.

Step 2: configuration

Everything that is "fixed data" goes in a single file. If tomorrow you want to change a color or a timing, you know where to look.

js/config.js:

/**
 * CONFIGURATION: all of the game's fixed data.
 * If you want to change a color or a timing, this is the file.
 */

/** Each operation ALWAYS has the same color: it is the child's visual anchor. */
export const OPERATIONS = {
  contar: { name: 'Contar', symbol: '', color: '#4f8ef7' },
  sumar: { name: 'Sumar', symbol: '+', color: '#ff7a4d' },
};

/** Cap colors, and each die's fixed color (die 1 blue, die 2 green...). */
export const CAP_COLORS = { blue: '#1d4ed8', green: '#15803d', red: '#b91c1c', orange: '#c2410c' };
export const DICE_COLORS = ['blue', 'green', 'red', 'orange'];

/** Timings, in milliseconds. */
export const TIMING = {
  nextRoundDelay: 2800, // after a correct answer
  demoStepDelay: 700, // per cap while the app helps
  endButtonsDelay: 4000, // the end buttons appear after this
};

export function defaultSettings() {
  return {
    operation: 'sumar',
    dice: 2, // how many dice
    faces: 3, // die faces: 1 to 3
    rounds: 5, // rounds per session
  };
}

Why is defaultSettings() a function and not a constant? Because every time you call it, it returns a new object. If it were a shared constant, changing the settings in one place would change them everywhere.

And a helper so the voice says numbers as words, js/spanish.js:

// prettier-ignore
const NUMBER_WORDS = [
  'cero', 'uno', 'dos', 'tres', 'cuatro', 'cinco', 'seis',
  'siete', 'ocho', 'nueve', 'diez', 'once', 'doce',
];

/** 3 → "tres". From 13 on it returns the digits, which speech engines read well. */
export function numberWord(n) {
  return n >= 0 && n < NUMBER_WORDS.length ? NUMBER_WORDS[n] : String(n);
}

Step 3: the game rules (pure logic)

This is the heart of the game and the most important file in the tutorial. Its job: take the settings and return a round. For example, if Add rolls a 2 and a 1, createRound() returns this object (you don't write it yourself; the code below builds it):

{
  operation: 'sumar',
  values: [2, 1],                         // what each die rolled
  groups: [['blue', 'blue'], ['green']],  // the caps, grouped, with their color
  answer: 3,
  prompt: '2 más 1 es igual a…',          // what is shown on screen
  speech: 'dos más uno es igual a…',      // what the voice says
  key: '2,1',                             // identifies the roll, so it isn't repeated
}

Notice that the round knows nothing about buttons or screens. It's just data. That's what we call "pure logic": a function that takes data and returns data, without touching the DOM, without making sound and without saving anything.

js/rules.js:

/**
 * GAME RULES: pure logic.
 * Takes settings, returns a round. Doesn't touch the DOM, doesn't make sound, doesn't save.
 * That's why it can be tested in Node, without a browser.
 */

import { DICE_COLORS } from './config.js';
import { numberWord } from './spanish.js';

/** Random integer between min and max, both included. */
export function randomInt(min, max, random = Math.random) {
  return min + Math.floor(random() * (max - min + 1));
}

const sum = (numbers) => numbers.reduce((a, b) => a + b, 0);

/** "2 más 1 es igual a…": digits on screen (String), words in the voice (numberWord). */
function operationPhrase(numbers, word, say) {
  return `${numbers.map(say).join(` ${word} `)} es igual a…`;
}

function rollDice(settings, random) {
  return Array.from({ length: settings.dice }, () => randomInt(1, settings.faces, random));
}

function buildContar(settings, random) {
  const values = rollDice(settings, random);
  const answer = sum(values);
  return {
    values,
    // A single pile, in the first die's color: no groups to join, just counting.
    groups: [Array(answer).fill(DICE_COLORS[0])],
    answer,
    prompt: '¿Cuántas hay?',
    speech: '¿Cuántas hay?',
  };
}

function buildSumar(settings, random) {
  const values = rollDice(settings, random);
  return {
    values,
    // One group per die, in that die's color.
    groups: values.map((value, i) => Array(value).fill(DICE_COLORS[i])),
    answer: sum(values),
    prompt: operationPhrase(values, 'más', String),
    speech: operationPhrase(values, 'más', numberWord),
  };
}

const BUILDERS = { contar: buildContar, sumar: buildSumar };

/**
 * Creates the next round, without repeating the previous roll.
 * @param {{ operation: string, dice: number, faces: number }} settings
 * @param {{ previousKey?: string, random?: () => number }} [options]
 */
export function createRound(settings, { previousKey = '', random = Math.random } = {}) {
  let round;
  for (let attempt = 0; attempt < 50; attempt++) {
    round = { operation: settings.operation, ...BUILDERS[settings.operation](settings, random) };
    round.key = round.values.join(',');
    if (round.key !== previousKey) break;
  }
  return round;
}

/** Three different answers, smallest to largest, including the correct one. */
export function makeAnswerOptions(answer, random = Math.random) {
  const nearby = [answer - 2, answer - 1, answer + 1, answer + 2].filter((n) => n >= 0);
  // Fisher-Yates shuffle
  for (let i = nearby.length - 1; i > 0; i--) {
    const j = Math.floor(random() * (i + 1));
    [nearby[i], nearby[j]] = [nearby[j], nearby[i]];
  }
  return [answer, nearby[0], nearby[1]].sort((a, b) => a - b);
}

/** "Hay tres." ("There are three.") */
export function resultPhrase(answer) {
  return answer === 1 ? 'Hay una.' : `Hay ${numberWord(answer)}.`;
}

Four ideas to take away from this file:

  1. One function per operation, and an object that groups them (BUILDERS). To add Subtract you don't touch createRound: you write buildRestar and add it to BUILDERS.
  2. Randomness can be swapped out. Every function takes random as a parameter, with Math.random as the default. In the tests we pass a predictable generator, so a test that fails always fails the same way.
  3. Nearby answers. The wrong options are 1 or 2 away from the correct one. If one were 3 and another 50, the child would guess without counting.
  4. Don't repeat the previous roll. If "2 plus 1" comes up twice in a row, it looks to the child like nothing happened. key identifies the roll and createRound rolls again if it repeats.

The phrase "es igual a…" ("equals…"). At first the game said "Juntamos. ¿Cuántas hay?" ("We join them. How many are there?"). One day, watching Numberblocks, her favorite show, with my daughter, I realized it was better to say the operation the way they say it there: "2 más 1 es igual a…" ("2 plus 1 equals…"). That way, from the very beginning, she hears the same language she already knows from the show and will use at school. Notice that the voice says "uno" and not "una" (the feminine form, as in "una tapita"): we're talking about numbers, not caps.

Step 4: test the rules before there is a screen

Since rules.js is pure logic, we can test it in the terminal before drawing a single button. Node ships with its own test runner (node:test), so there's nothing to install.

tests/rules.test.js:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRound, makeAnswerOptions } from '../js/rules.js';

/** Repeatable random numbers: if a test fails, it always fails the same way. */
function seededRandom(seed = 42) {
  let value = seed;
  return () => {
    value = (value * 1664525 + 1013904223) % 4294967296;
    return value / 4294967296;
  };
}

for (const operation of ['contar', 'sumar']) {
  test(`${operation}: 500 rondas que cumplen las reglas`, () => {
    const settings = { operation, dice: 2, faces: 3 };
    const random = seededRandom();
    let previousKey = '';

    for (let i = 0; i < 500; i++) {
      const round = createRound(settings, { previousKey, random });
      const caps = round.groups.flat().length;

      assert.ok(
        round.values.every((v) => v >= 1 && v <= 3),
        'cada dado va de 1 a 3',
      );
      assert.equal(caps, round.answer, 'hay tantas tapitas como la respuesta');
      assert.notEqual(round.key, previousKey, 'no repite la tirada anterior');

      const options = makeAnswerOptions(round.answer, random);
      assert.equal(new Set(options).size, 3, 'tres respuestas distintas');
      assert.ok(options.includes(round.answer), 'incluye la correcta');
      assert.ok(
        options.every((n) => n >= 0),
        'ninguna negativa',
      );
      previousKey = round.key;
    }
  });
}

test('Sumar se dice como se lee', () => {
  const round = createRound({ operation: 'sumar', dice: 2, faces: 3 }, { random: () => 0.5 });
  assert.equal(round.prompt, '2 más 2 es igual a…');
  assert.equal(round.speech, 'dos más dos es igual a…');
});

The test names and messages are in Spanish, like the repository. In order: "500 rounds that follow the rules", "each die goes from 1 to 3", "as many caps as the answer", "doesn't repeat the previous roll", "three different answers", "includes the correct one", "none negative", and "Add is said the way it's read".

Run them:

node --test
✔ contar: 500 rondas que cumplen las reglas
✔ sumar: 500 rondas que cumplen las reglas
✔ Sumar se dice como se lee
ℹ pass 3
ℹ fail 0

Look at what they test: not "that the function returns something", but the rules that matter for the child. Never a negative answer, always three different options, never the same roll twice. And not one round: 500. Randomness hides bugs that show up once every hundred rounds.

How does seededRandom work? It's a linear congruential generator: each number comes from the previous one with a multiplication and an addition. It looks random, but with the same seed (42) it always produces the same sequence.

Step 5: state and actions

Now we need a place that remembers what is happening: which screen we're on, which round is being played, which caps have already been counted. That place is state, a plain object.

The golden rule: nobody but game.js changes state. The UI calls an action (tapCap, chooseAnswer…), the action changes the state and calls notify(), and notify() tells whoever is listening (in step 7, the drawing function).

js/game.js, first part:

/**
 * STATE AND ACTIONS.
 * The UI never changes `state` directly: it calls an action, the action
 * changes the state and announces it with notify(), and the screen is redrawn.
 *
 *     click → action → state → notify() → render()
 */

import { defaultSettings, TIMING } from './config.js';
import { createRound, makeAnswerOptions, resultPhrase } from './rules.js';
import { numberWord } from './spanish.js';
import { say, stopVoice, playCount, playSuccess, playMiss } from './audio.js';

export const state = {
  settings: defaultSettings(),
  screen: 'home', // 'home' | 'game' | 'end'
  phase: 'play', // 'play' | 'helping' | 'solved'
  roundNumber: 0, // rounds finished in this session
  round: null,
  answerOptions: [],
  countedCaps: new Map(), // cap id → number it was given
  highlightedCap: null, // the cap that grows during the help
  pulsingAnswer: null, // the correct answer that pulses after the help
  helpShown: false,
  endButtonsVisible: false,
};

// --- Announcing changes -----------------------------------------------------

const listeners = [];

export function onChange(listener) {
  listeners.push(listener);
}

function notify() {
  listeners.forEach((listener) => listener(state));
}

phase matters: it says what the child can do right now.

phase What it means What it accepts
play the child can tap caps, answers
helping the app is counting nothing
solved correct; the next round follows in a moment nothing

The problem with tasks that wait

Some actions take time: the help counts one cap every 700 ms; after a correct answer there's a 2.8 s wait before the next round. What happens if the child goes back home in the middle of the help? The task keeps running and the voice keeps counting on the home screen.

The solution is simple: each round has a number. Each task notes the number it started with and, after every wait, checks whether it's still the same. If it isn't, it stops.

Add it to js/game.js, below notify():

// --- Waiting tasks that cancel themselves -----------------------------------
// Each round has a number. A task stores the number it started with and,
// if it changed while waiting (for example, the child went back home), it stops.

let roundId = 0;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const isStale = (id) => id !== roundId;

goHome() and every new round increment roundId, and with that, all old tasks cancel themselves.

Navigation and rounds

Continue in js/game.js, below the previous block:

// --- Navigation ---------------------------------------------------------------

export function startOperation(operation) {
  state.settings.operation = operation;
  startSession();
}

export function startSession() {
  state.roundNumber = 0;
  state.screen = 'game';
  startRound();
}

export function goHome() {
  roundId++;
  stopVoice();
  state.screen = 'home';
  notify();
}

export function repeatPrompt() {
  if (state.round) say(state.round.speech);
}

// --- Rounds -------------------------------------------------------------------

function startRound() {
  roundId++;
  const round = createRound(state.settings, { previousKey: state.round?.key });
  Object.assign(state, {
    round,
    answerOptions: makeAnswerOptions(round.answer),
    phase: 'play',
    countedCaps: new Map(),
    highlightedCap: null,
    pulsingAnswer: null,
    helpShown: false,
  });
  notify();

  // Announce the end ahead of time: the closing never comes as a surprise.
  const roundsLeft = state.settings.rounds - state.roundNumber;
  let intro = '';
  if (roundsLeft === 2) intro = 'Dos más y guardamos. ';
  else if (roundsLeft === 1) intro = 'La última y guardamos. ';
  say(intro + round.speech);
}

function finishSession() {
  roundId++;
  const id = roundId;
  state.screen = 'end';
  state.endButtonsVisible = false;
  notify();
  say('Terminamos. Guardamos las tapitas.');
  // The buttons appear later, so the ending feels like an ending.
  setTimeout(() => {
    if (isStale(id)) return;
    state.endButtonsVisible = true;
    notify();
  }, TIMING.endButtonsDelay);
}

Here are two of the research rules turned into code: the "Dos más y guardamos" warning and the buttons that appear after 4 seconds.

The child's taps, and help without "no"

And at the end of js/game.js:

// --- The child's taps ---------------------------------------------------------

/** Tapping a cap counts it: one, two, three... */
export function tapCap(capId) {
  if (state.phase !== 'play') return;
  const counted = state.countedCaps.get(capId);
  if (counted !== undefined) {
    say(numberWord(counted)); // already counted: just repeat its number
    return;
  }
  const number = state.countedCaps.size + 1;
  state.countedCaps.set(capId, number);
  playCount(number);
  say(numberWord(number));
  notify();
}

export function chooseAnswer(value) {
  const { round } = state;
  if (!round || state.phase !== 'play') return;

  if (value === round.answer) {
    celebrate(round);
    return;
  }
  // No "no" and no punishment: a soft tone, and the app helps count.
  playMiss();
  if (!state.helpShown) {
    state.helpShown = true;
    showHelp();
  }
}

function celebrate(round) {
  const id = roundId;
  state.phase = 'solved';
  state.roundNumber++;
  playSuccess();
  notify();
  say(`Sí. ${resultPhrase(round.answer)}`);

  setTimeout(() => {
    if (isStale(id)) return;
    if (state.roundNumber >= state.settings.rounds) finishSession();
    else startRound();
  }, TIMING.nextRoundDelay);
}

/** The app counts out loud, cap by cap, and the correct answer starts pulsing. */
async function showHelp() {
  const id = roundId;
  const { round } = state;
  state.phase = 'helping';
  state.countedCaps.clear();
  notify();

  const total = round.groups.flat().length;
  for (let i = 0; i < total; i++) {
    const capId = `cap-${i}`;
    state.countedCaps.set(capId, i + 1);
    state.highlightedCap = capId;
    notify();
    say(numberWord(i + 1));
    await wait(TIMING.demoStepDelay);
    if (isStale(id)) return;
  }

  state.highlightedCap = null;
  notify();
  await say(resultPhrase(round.answer));
  if (isStale(id)) return;
  state.pulsingAnswer = round.answer;
  state.phase = 'play';
  notify();
}

Look at tapCap: the countedCaps Map is one-to-one correspondence in code. A cap that has already been counted doesn't add one more: it repeats its number.

And showHelp is the "never say no" rule: instead of marking the mistake, the app counts again from zero, cap by cap, says "Hay cuatro." (cardinality) and leaves the correct answer pulsing. The child is the one who taps it.

Step 6: drawing the screen

render.js takes the state and builds HTML as text. Every time something changes, the whole zone is redrawn with innerHTML. It's the simplest model there is and, for a few dozen elements, it's more than fast enough.

The die: a 3×3 grid

A die is a grid of 9 cells; some of them have a pip. All you need is a table that says which cells get painted for each number:

 0 │ 1 │ 2        5:   ● │   │ ●
───┼───┼───           ───┼───┼───
 3 │ 4 │ 5               │ ● │
───┼───┼───           ───┼───┼───
 6 │ 7 │ 8             ● │   │ ●     → [0, 2, 4, 6, 8]

js/render.js:

/**
 * DRAWING: reads the state and builds HTML. Never changes the state.
 */

import { OPERATIONS, CAP_COLORS, DICE_COLORS } from './config.js';

const $ = (id) => document.getElementById(id);

/** Pip positions on the die's 3×3 grid (0 = top left). */
const PIP_POSITIONS = {
  1: [4],
  2: [0, 8],
  3: [0, 4, 8],
  4: [0, 2, 6, 8],
  5: [0, 2, 4, 6, 8],
  6: [0, 2, 3, 5, 6, 8],
};

function dieHTML(value, colorId) {
  const cells = Array.from({ length: 9 }, (_, i) =>
    PIP_POSITIONS[value].includes(i) ? '<span class="die__pip"></span>' : '<span></span>',
  ).join('');
  return `<div class="die" style="--edge:${CAP_COLORS[colorId]}" role="img" aria-label="Dado: ${value}">${cells}</div>`;
}

function capHTML(id, colorId, state) {
  const number = state.countedCaps.get(id);
  const highlighted = state.highlightedCap === id ? 'cap--highlighted' : '';
  return `<button type="button" class="cap ${highlighted}" style="--cap-color:${CAP_COLORS[colorId]}"
    data-action="tap-cap" data-cap="${id}" aria-label="Tapita${number ? `, ${number}` : ''}">${number ?? ''}</button>`;
}

Each cap is a real <button>: that way it works with a finger, a mouse and a keyboard. The die has role="img" and aria-label="Dado: 5" ("Die: 5") so a screen reader announces the number.

The color goes in a CSS variable (--cap-color) and not in one class per color. That way the CSS has a single .cap rule and the color comes from config.js.

The screens

At the end of js/render.js, below capHTML:

function renderHome() {
  $('operation-tiles').innerHTML = Object.entries(OPERATIONS)
    .map(
      ([id, operation]) => `
      <button type="button" class="tile" style="--tile-color:${operation.color}"
        data-action="start-operation" data-operation="${id}">${operation.name}</button>`,
    )
    .join('');
}

function renderGame(state) {
  const { round } = state;
  const operation = OPERATIONS[round.operation];

  $('prompt').textContent = `🔊 ${round.prompt}`;

  const result = state.phase === 'solved' ? round.answer : '?';
  $('dice-row').innerHTML =
    round.values.map((value, i) => dieHTML(value, DICE_COLORS[i])).join(`<span>${operation.symbol}</span>`) +
    `<span>=</span><span>${result}</span>`;

  // Each cap has a unique id (cap-0, cap-1...) so we know which one was counted.
  let capIndex = 0;
  $('table').innerHTML = round.groups
    .map((group) => {
      const caps = group.map((colorId) => capHTML(`cap-${capIndex++}`, colorId, state)).join('');
      return `<div class="cap-group" style="--columns:${Math.min(5, group.length)}">${caps}</div>`;
    })
    .join('');

  $('answers').innerHTML = state.answerOptions
    .map((value) => {
      const pulsing = state.pulsingAnswer === value ? 'is-pulsing' : '';
      return `<button type="button" class="answer ${pulsing}" data-action="choose-answer" data-value="${value}">${value}</button>`;
    })
    .join('');
}

export function render(state) {
  $('screen-home').hidden = state.screen !== 'home';
  $('screen-game').hidden = state.screen !== 'game';
  $('screen-end').hidden = state.screen !== 'end';

  // The background takes the operation's color.
  const color = state.screen === 'home' ? '#ffa23a' : OPERATIONS[state.settings.operation].color;
  document.documentElement.style.setProperty('--theme', color);

  if (state.screen === 'home') renderHome();
  if (state.screen === 'game') renderGame(state);
  if (state.screen === 'end') $('end-buttons').hidden = !state.endButtonsVisible;
}

--columns: Math.min(5, group.length) is what arranges the caps in rows of 5, like an egg carton.

Careful with innerHTML. It's safe here because everything interpolated comes from config.js or is a computed number: never text a person typed. If you ever display user text, escape it first (the real project has an escapeHTML function for that).

Step 7: wiring up the clicks

We could add an addEventListener to each button, but the buttons are created and destroyed on every render. Instead we use event delegation: a single listener on the whole document that, on every click, looks for the nearest data-action.

js/main.js:

/**
 * ENTRY POINT: connects the state to the drawing and turns each click into an action.
 */

import * as game from './game.js';
import { render } from './render.js';
import { unlockAudio } from './audio.js';

/** What each data-action does. For a new button: give it a data-action and add a line here. */
const ACTIONS = {
  'start-operation': (el) => {
    unlockAudio(); // first tap: the browser now allows sound
    game.startOperation(el.dataset.operation);
  },
  'repeat-prompt': () => game.repeatPrompt(),
  'tap-cap': (el) => game.tapCap(el.dataset.cap),
  'choose-answer': (el) => game.chooseAnswer(Number(el.dataset.value)),
  'go-home': () => game.goHome(),
  'play-again': () => game.startSession(),
};

// A single listener for the whole page (event delegation).
// closest() finds the button even when the tap lands on the number inside it,
// and it works with buttons created after the page loaded.
document.addEventListener('click', (event) => {
  const element = event.target.closest('[data-action]');
  if (element) ACTIONS[element.dataset.action]?.(element);
});

game.onChange(render);
render(game.state);

game.onChange(render) is the piece that closes the loop: every time an action calls notify(), render(state) runs.

Step 8: voice and sounds without audio files

The browser ships two APIs that save us from recording audio:

  • Web Speech API (speechSynthesis): turns text into speech.
  • Web Audio API (AudioContext): generates tones with an oscillator.

js/audio.js:

/**
 * VOICE (Web Speech API) AND SOUNDS (Web Audio API). No audio files.
 */

const hasSpeech = 'speechSynthesis' in window;
let spanishVoice = null;

/** Prefers a Spanish voice that runs on the device itself (privacy). */
function pickSpanishVoice() {
  const voices = speechSynthesis.getVoices().filter((voice) => voice.lang.toLowerCase().startsWith('es'));
  spanishVoice = voices.find((voice) => voice.localService) ?? voices[0] ?? null;
}

if (hasSpeech) {
  pickSpanishVoice();
  // The voice list may arrive empty at first and fill in later.
  speechSynthesis.addEventListener('voiceschanged', pickSpanishVoice);
}

/**
 * Speaks a phrase and interrupts the previous one: if the child taps fast, they
 * hear the latest number, not a queue of numbers. Resolves when speech ends.
 */
export function say(text) {
  return new Promise((resolve) => {
    if (!hasSpeech) return resolve();
    speechSynthesis.cancel();
    const utterance = new SpeechSynthesisUtterance(text);
    utterance.lang = spanishVoice?.lang ?? 'es-MX';
    if (spanishVoice) utterance.voice = spanishVoice;
    utterance.rate = 0.9; // a bit slower than normal
    utterance.onend = resolve;
    utterance.onerror = resolve;
    speechSynthesis.speak(utterance);
    setTimeout(resolve, 5000); // in case the browser never reports that it finished
  });
}

export function stopVoice() {
  if (hasSpeech) speechSynthesis.cancel();
}

let audioContext = null;

/** Browsers don't allow sound until the person touches the screen. */
export function unlockAudio() {
  audioContext ??= new AudioContext();
  if (audioContext.state === 'suspended') audioContext.resume().catch(() => {});
  if (hasSpeech) pickSpanishVoice();
}

/** A short tone that fades out on its own. */
export function playTone(frequency, duration = 0.1, volume = 0.12) {
  if (!audioContext) return;
  const now = audioContext.currentTime;
  const oscillator = audioContext.createOscillator();
  const gain = audioContext.createGain();
  oscillator.frequency.value = frequency;
  gain.gain.setValueAtTime(volume, now);
  gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
  oscillator.connect(gain).connect(audioContext.destination);
  oscillator.start(now);
  oscillator.stop(now + duration);
}

/** Three soft notes for a correct answer (C, E, G). */
export function playSuccess() {
  [523, 659, 784].forEach((note, i) => setTimeout(() => playTone(note, 0.18), i * 110));
}

/** A short, low tone for a wrong answer. Soft: it is not a punishment. */
export function playMiss() {
  playTone(240, 0.14, 0.08);
}

/** The pitch rises with each cap: the child "hears" the number grow. */
export function playCount(n) {
  playTone(330 + Math.min(n, 20) * 20, 0.08);
}

Three details that took a while to learn:

  • Audio is blocked until the first tap. Browsers, and Safari on iPhone above all, won't play sound on a page the user hasn't touched. That's why unlockAudio() is called when an operation is chosen.
  • say returns a promise. That way the help can wait for a phrase to finish with await say(...). The 5 s setTimeout is a safety net: some browsers occasionally never report that they finished.
  • Privacy: some voices (for example, Chrome's "Google…" voices) send the text to a server to turn it into audio. Voices with localService === true run on the device itself. That's why they're preferred.

And a design detail: playCount raises the pitch with each cap. Without a word, the child hears the number grow.

Want it to speak English?

The game was built in Spanish, but the language lives in very few places:

  1. spanish.js: replace NUMBER_WORDS with 'zero', 'one', 'two'….
  2. rules.js: operationPhrase builds es igual a…; change it to equals…, change 'más' to 'plus', and translate '¿Cuántas hay?' and resultPhrase.
  3. game.js: the closing phrases ('Dos más y guardamos. ', 'Terminamos…') and the Sí. in celebrate.
  4. audio.js: filter voices with startsWith('en') and use 'en-US' as the fallback.
  5. index.html: lang="en" and the texts on the end screen.

The tests in step 4 will tell you if you missed one: Sumar se dice como se lee checks the exact phrase.

Step 9: styles

The full CSS is in the appendix, at the end of the article. These are the parts worth understanding.

The die, with CSS Grid:

/* The die: a 3×3 grid where some cells have a pip. */
.die {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 3px;
  width: 56px;
  height: 56px;
  padding: 6px;
  box-sizing: border-box;
  border: 3px solid var(--edge);
  border-radius: 12px;
  background: #fff;
}

.die__pip {
  border-radius: 50%;
  background: var(--edge);
}

The caps in rows of 5, using the --columns variable that render.js sets:

/* A group of caps in rows of up to 5, like an egg carton. */
.cap-group {
  display: grid;
  grid-template-columns: repeat(var(--columns), auto);
  gap: 8px;
}

.cap {
  width: var(--cap-size);
  height: var(--cap-size);
  border: 3px dashed rgb(255 255 255 / 45%);
  border-radius: 50%;
  background: var(--cap-color);
  color: #fff;
  font-size: 20px;
  font-weight: 700;
}

The pulsing answer, and the rule that turns it off when the system asks for reduced motion:

.answer.is-pulsing {
  animation: pulse 1s ease-in-out infinite;
}

@keyframes pulse {
  50% {
    transform: scale(1.12);
  }
}

/* If the system asks to "reduce motion", nothing moves. */
@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
    transition: none !important;
  }
}

prefers-reduced-motion reads an accessibility option from the operating system (for example, "Reduce Motion" on iPhone and Mac). Respecting it costs a few lines.

Checkpoint

Start the server, open http://localhost:3000 and play. You should be able to:

  • Choose Contar or Sumar, and see the background change to the operation's color.
  • Hear "2 más 1 es igual a…".
  • Tap the caps and hear "uno, dos, tres" with a rising tone.
  • Choose a wrong answer and watch the app count on its own while the correct answer starts pulsing.
  • Hear "Dos más y guardamos" in the fourth round of five.
  • Reach the end and see the buttons only after 4 seconds.

Exercise: add Subtract

With what you already have, you can add an operation without touching createRound:

  1. In config.js, add restar: { name: 'Restar', symbol: '−', color: '#ff5d8f' } to OPERATIONS (restar = subtract).
  2. In rules.js, write buildRestar(settings, random): the first die says how many caps to put down; the second, how many to take away. Make sure the second isn't larger than the first (no negative answers!). For the phrase, use operationPhrase(values, 'menos', String) (menos = minus).
  3. Register it in BUILDERS and add 'restar' to the test's list of operations.

Run node --test. If the "ninguna negativa" (none negative) test fails, you know where to look.

From prototype to real project

The minimal version works, but the real project had to solve quite a bit more. This section shows those problems with excerpts from the real code and tells how they were found. Several only showed up in an audit, when the game already "worked".

1. "Never ask for more caps than there are"

In "Tapitas reales" (real caps) mode, the screen tells the adult what to take out of the box: "Saca 3 azules y 2 verdes" ("Take out 3 blue and 2 green"). The adult sets up how many caps of each color they have. The rule: the app can never ask for more than there are.

The first version tried a few rolls and, if none fit in the box, accepted any of them. With a box holding a single black cap and die 1 colored black, Subtract asked for caps that didn't exist in 200 out of 200 rounds. The fix: an explicit order of preference, and a last option that always follows the rule.

for (let attempt = 0; attempt < 120; attempt++) {
  const round = tryBuild();
  if (round && round.key !== previousKey && fits(round)) return round;
}
// Small box, or a die color with few caps: repeating the previous roll
// is better than asking for caps that aren't there.
for (let attempt = 0; attempt < 200; attempt++) {
  const round = tryBuild();
  if (round && fits(round)) return round;
}
return countingFallback(settings, random);

First, a new round that fits. If none shows up, one that fits even if it repeats the previous one. And if that fails too, countingFallback builds a Count round with the color that has the most caps. The lesson: when a rule is sacred, the code has to guarantee it on every path, including the last one. And the test has to use small boxes on purpose.

2. Don't trust localStorage

Settings are saved in the browser so the adult doesn't have to set up the box every time. But localStorage is text that anyone can edit from the browser's dev tools. The first version validated the operation like this:

raw.operation in OPERATIONS;

And 'toString' in OPERATIONS is true: the in operator also looks at what the object inherits. A setting with "operation": "toString" broke the game. The real version uses Object.hasOwn and validates each field against a closed list:

export function normalizeSettings(raw) {
  const settings = defaultSettings();
  if (!isPlainObject(raw)) return settings;

  if (isOperationId(raw.operation)) settings.operation = raw.operation;
  if (Number.isInteger(raw.dice)) settings.dice = /** @type {number} */ (raw.dice);
  if (raw.preferredLevel === 2 || raw.preferredLevel === 3 || raw.preferredLevel === 4) {
    settings.preferredLevel = raw.preferredLevel;
  }
  if (raw.faces === 3 || raw.faces === 6) settings.faces = raw.faces;
  if (raw.rounds === 3 || raw.rounds === 5 || raw.rounds === 8) settings.rounds = raw.rounds;
  if (typeof raw.voice === 'boolean') settings.voice = raw.voice;
  if (typeof raw.sound === 'boolean') settings.sound = raw.sound;
  if (typeof raw.realCaps === 'boolean') settings.realCaps = raw.realCaps;

The pattern: start from the defaults and copy only what passes validation. Never the other way around.

3. The keyboard lost its place on every tap

innerHTML deletes the buttons and creates new ones. If keyboard focus was on one of them, the browser sends it to <body>, and a keyboard user has to start from the top after every tap. It happens in the minimal version; try it with Tab.

The real project draws each zone with updateZone(), which leaves a zone alone if it hasn't changed and, if focus was inside, returns it to the "same" button:

export function updateZone(zone, html) {
  if (lastHTML.get(zone) === html) return;
  lastHTML.set(zone, html);

  const active = document.activeElement;
  const hadFocus = active instanceof HTMLElement && active !== zone && zone.contains(active);
  const selector = hadFocus ? identitySelector(active) : '';
  const action = hadFocus ? active.getAttribute('data-action') : null;

  zone.innerHTML = html;
  applyStyleVars(zone);
  if (!hadFocus) return;

  const target =
    (selector && zone.querySelector(selector)) ||
    (action && zone.querySelector(`[data-action="${CSS.escape(action)}"]`)) ||
    zone.querySelector('button, [tabindex="0"], input, select');
  if (target instanceof HTMLElement) target.focus();
}

"The same button" is recognized by its attributes (data-action, data-cap, data-value…), because the element itself is new. It's not a virtual DOM: it compares HTML text and nothing else. But it solves the problem.

Other accessibility improvements in the real project, all small:

  • aria-disabled instead of disabled on the answers while the app is helping. disabled removes the button from the keyboard order and focus gets lost.
  • inert on the end buttons while they're hidden: they can't be reached with Tab either.
  • A single aria-live region that announces the question and the result. Before, the whole table was announced on every tap.
  • Contrast: text on each operation's color is dark, with at least 4.5:1 contrast, without changing the operation colors (they are the child's anchor).
  • When the screen changes, focus moves to the title or the question.

4. Security: a strict CSP without losing the colors

A Content Security Policy (CSP) tells the browser what the page is allowed to load. The real project's is very strict: only files from the site itself and no styles written inside the HTML. That includes the style="--cap-color:..." attributes we used in the minimal version.

The fix: the HTML carries the colors in a data attribute, and JavaScript applies them with element.style.setProperty, which the CSP does allow:

export function applyStyleVars(root) {
  for (const element of root.querySelectorAll('[data-vars]')) {
    if (!(element instanceof HTMLElement || element instanceof SVGElement)) continue;
    for (const declaration of (element.dataset.vars ?? '').split(';')) {
      const [name, value] = declaration.split(':').map((part) => part.trim());
      if (name?.startsWith('--') && value) element.style.setProperty(name, value);
    }
  }
}

In the HTML, style="--edge:#1d4ed8" becomes data-vars="--edge:#1d4ed8". The CSS doesn't change. And the function only accepts variables (names that start with --), never any other property.

5. Privacy: nothing leaves the device

It's a game for children, so the goal was that no data leaves the device. Two findings:

  • The first version loaded the Fredoka font from Google Fonts. That sends the device's IP address to a third party on every visit. Now the font is served from the site itself: a single woff2 file of about 30 KB, with its license (OFL).
  • Voice selection, as you saw in step 8, prefers local voices.

An automated test checks that no request leaves the site itself. The saved settings contain nothing about the child: no name, no age, no scores.

6. Working offline

A service worker is a script the browser runs separately from the page that can answer its requests. The real project uses the easiest strategy to reason about, "network first": with internet it always downloads the published version (and saves a copy); without internet it uses the copy.

// Every request: network first; if it fails, the saved copy.
self.addEventListener('fetch', (event) => {
  const { request } = event;
  if (request.method !== 'GET' || new URL(request.url).origin !== self.location.origin) return;

  event.respondWith(
    fetch(request)
      .then((response) => {
        if (response.ok) {
          const copy = response.clone();
          caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
        }
        return response;
      })
      .catch(async () => (await caches.match(request)) ?? (await caches.match('index.html')) ?? Response.error()),
  );
});

With a manifest.webmanifest and its icons, the game can also be installed on the home screen of a phone or tablet and opened full screen.

7. Tools that look after the code

None of these tools ship with the site. They're for development only (devDependencies):

Tool What for
JSDoc + tsc --noEmit Types written in comments. TypeScript checks them in strict mode without compiling.
ESLint Catches common mistakes, like unused variables.
Prettier Uniform formatting: nobody argues about spaces or quotes.
node:test Unit tests for the rules, the state and storage, without a browser.
Playwright Plays the game for real in a browser, at phone size and at desktop size.

One of my favorite Playwright tests checks the game's most important rule: after a wrong answer, no phrase from the voice contains the word "no".

test('Respuesta equivocada: ayuda completa, sin "no", y la correcta late', async ({ page }) => {
  await page.goto('/');
  await startOperation(page, 'sumar', 2);
  const { answer } = await gameState(page);
  const wrong = page.locator(`[data-action="choose-answer"]:not([data-value="${answer}"])`).first();
  await wrong.click();

  await expect.poll(async () => (await gameState(page)).mood).toBe('think');
  await expect(page.locator('.answer[aria-disabled="true"]')).toHaveCount(3); // while helping
  await expect(page.locator(`.answer.is-pulsing[data-value="${answer}"]`)).toBeVisible({ timeout: 30_000 });
  await expect(page.locator('.cap--counted')).toHaveCount(Number(answer));

  for (const phrase of await spoken(page)) expect(phrase).not.toMatch(/\bno\b/i);
});

(The test name reads: "Wrong answer: full help, no 'no', and the correct one pulses".)

The child's rules are requirements, and requirements get tested. If someone changes a phrase a year from now, the test will tell them.

Publishing it

It's a static site: you publish the folder as it is, with no build.

Hosting Security headers What to do
Cloudflare Pages Yes, from the _headers file Connect the repository. Build command: none. Output directory: /.
Netlify Yes, from the _headers file Connect the repository. Build command: empty. Publish directory: ..
GitHub Pages Doesn't allow custom headers Publish from the main branch. The CSP still applies through a <meta> tag in the HTML.

Once published, it opens in any modern browser and, after the first visit, works offline.

What I learned

  • The user defines the rules; the code guarantees them. "Never say no" or "never ask for more caps than there are" aren't details: they're requirements. Each one has its test.
  • Simple scales further than it seems. Three screens, one state object and innerHTML were enough for a complete, accessible, tested game.
  • Pure logic tests itself. Separating rules.js from the DOM made it possible to test thousands of rounds in milliseconds, before there was a screen.
  • Accessibility is lots of small details. None was hard; all of them would have been missed without testing with a keyboard and a screen reader.
  • Other people's feedback makes the game better. "Count needs levels too" came from people who used it, and "2 más 1 es igual a…" came from watching Numberblocks with my daughter.

Glossary

Term What it is
ES module A .js file that uses import and export. Loaded with <script type="module">.
Pure logic Code that takes data and returns data, with no side effects: doesn't touch the screen, no sound, no saving.
State (state) The object that remembers what's happening in the game.
Event delegation A single listener on a parent element that handles the clicks of all its children.
data-* Custom attributes in the HTML (data-action, data-cap) that JavaScript reads with element.dataset.
CSS variable A named value (--cap-color) that CSS uses with var(--cap-color).
CSP Content Security Policy: rules that tell the browser what the page can load and run.
Service worker A script the browser runs separately from the page that makes it possible, for example, to work offline.
aria-* Attributes that describe the page for screen readers.
Subitizing Recognizing how many objects there are without counting them, for small quantities.
Cardinality Knowing that the last number said while counting is how many objects there are.

Appendix: the full CSS of the minimal version

css/styles.css:

:root {
  --theme: #ffa23a; /* render.js changes it per operation */
  --cap-size: 48px;
  font-family: system-ui, sans-serif;
}

body {
  margin: 0;
  min-height: 100vh;
  background: linear-gradient(var(--theme), #fff 60%);
}

.app {
  max-width: 600px;
  margin: 0 auto;
  padding: 16px;
  text-align: center;
}

button {
  font: inherit;
  cursor: pointer;
}

.tiles {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 12px;
}

.tile {
  padding: 24px 12px;
  border: 0;
  border-radius: 20px;
  background: var(--tile-color);
  font-size: 24px;
  font-weight: 700;
}

.prompt {
  padding: 12px 20px;
  border: 0;
  border-radius: 999px;
  background: #fff;
  font-size: 22px;
}

.dice-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  margin: 16px 0;
  font-size: 36px;
  font-weight: 700;
}

/* The die: a 3×3 grid where some cells have a pip. */
.die {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 3px;
  width: 56px;
  height: 56px;
  padding: 6px;
  box-sizing: border-box;
  border: 3px solid var(--edge);
  border-radius: 12px;
  background: #fff;
}

.die__pip {
  border-radius: 50%;
  background: var(--edge);
}

.table {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 16px;
  padding: 16px;
  border-radius: 20px;
  background: #e8b77a;
}

/* A group of caps in rows of up to 5, like an egg carton. */
.cap-group {
  display: grid;
  grid-template-columns: repeat(var(--columns), auto);
  gap: 8px;
}

.cap {
  width: var(--cap-size);
  height: var(--cap-size);
  border: 3px dashed rgb(255 255 255 / 45%);
  border-radius: 50%;
  background: var(--cap-color);
  color: #fff;
  font-size: 20px;
  font-weight: 700;
}

.cap--highlighted {
  transform: scale(1.2);
}

.answers {
  display: flex;
  justify-content: center;
  gap: 12px;
  margin-top: 16px;
}

.answer {
  width: 90px;
  height: 90px;
  border: 0;
  border-radius: 20px;
  background: #fff;
  font-size: 40px;
  font-weight: 700;
}

.answer.is-pulsing {
  animation: pulse 1s ease-in-out infinite;
}

@keyframes pulse {
  50% {
    transform: scale(1.12);
  }
}

/* If the system asks to "reduce motion", nothing moves. */
@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
    transition: none !important;
  }
}

If you build your own version, or use it with a child, I'd love to hear how it went.

來源:DEV Community · dev.to