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
- The idea
- The research: what a child needs to learn to count
- Technical decisions: why no framework
- Step 0: folders and a local server
- Step 1: the HTML for the three screens
- Step 2: configuration
- Step 3: the game rules (pure logic)
- Step 4: test the rules before there is a screen
- Step 5: state and actions
- Step 6: drawing the screen
- Step 7: wiring up the clicks
- Step 8: voice and sounds without audio files
- Step 9: styles
- From prototype to real project
- Publishing it
- What I learned
- Glossary
- 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 useimport/exportand runs the script once the HTML has loaded. No need to wait forDOMContentLoaded. - No button has an
onclick. They carrydata-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:
- One function per operation, and an object that groups them (
BUILDERS). To add Subtract you don't touchcreateRound: you writebuildRestarand add it toBUILDERS. - Randomness can be swapped out. Every function takes
randomas a parameter, withMath.randomas the default. In the tests we pass a predictable generator, so a test that fails always fails the same way. - 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.
- Don't repeat the previous roll. If "2 plus 1" comes up twice in a row, it looks to the child like nothing happened.
keyidentifies the roll andcreateRoundrolls 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.everySource:https://dev.to/enlabe/matematicas-con-dados-how-i-built-a-game-to-help-my-little-girl-learn-to-count-with-dice-and-real-2n3b Related posts

The Effort to Reach: Designing for Fitts’s Law
GitHub’s New Dependabot Verdict Can Lag Behind Your Next Commit
