Un juego web hecho con HTML, CSS y JavaScript, sin framework, pensado para los peques. En este tutorial lo construimos desde cero: la idea, la investigación detrás de cada decisión y el código, paso a paso.
Para quién es: programadores que están empezando y personas entusiastas de la programación con conocimiento básico de HTML y JavaScript. Si sabes qué es una función y un array, puedes seguirlo.
Qué vas a construir: una versión mínima pero completa del juego, con Contar y Sumar, voz en español, ayuda cuando el niño se equivoca y una pantalla de cierre.
Código completo: https://github.com/the-bunker-code/matematicas-con-dados
Vamos a jugar: https://the-bunker-code.github.io/matematicas-con-dados/
Índice
- La idea
- La investigación: qué necesita un niño para aprender a contar
- Decisiones técnicas: por qué sin framework
- Paso 0: carpetas y servidor local
- Paso 1: el HTML de las tres pantallas
- Paso 2: la configuración
- Paso 3: las reglas del juego (lógica pura)
- Paso 4: probar las reglas antes de tener pantalla
- Paso 5: el estado y las acciones
- Paso 6: dibujar la pantalla
- Paso 7: conectar los clics
- Paso 8: voz y sonidos sin archivos de audio
- Paso 9: los estilos
- Del prototipo al proyecto real
- Publicarlo
- Lo que aprendí
- Glosario
- Anexo: el CSS completo de la versión mínima
1. La idea
Todo empezó sobre una mesita para niños, con una caja blanca llena de tapitas de plástico y un teléfono apoyado al lado.
A sus casi 4 años, mi niña aprende con las manos: necesita palpar, mover, apilar y ordenar antes de que un símbolo abstracto tenga sentido. Las apps tradicionales de matemáticas hacían justo lo contrario: llenaban la pantalla de luces parpadeantes, ruidos estridentes al cometer un error y recompensas vacías que la absorbían en el cristal y la desconectaban de su entorno.
Decidí plantear la interacción al revés: la app no reemplaza a las tapitas, las acompaña.
El teléfono, apoyado en la mesa, propone el reto en silencio o con una voz tranquila. Ella mira los dos dados en la pantalla: un 4 y un 3. Mete la mano en la caja, saca cuatro tapitas rojas y tres verdes, las ordena en filas y las cuenta en voz alta, tocando cada una con el dedo. Solo después de haber sentido la cantidad en sus manos vuelve a la pantalla y marca el resultado. A su lado siempre hay un adulto que no la interrumpe; solo la acompaña a su ritmo.
Estas reglas nacieron de verla jugar en esa mesa, y el código las respeta al pie de la letra:
- Sin castigos ni ruidos fuertes. Nunca hay un "no" cortante ni un sonido de error. Si se equivoca, la app cuenta junto a ella y le muestra el camino.
- La pantalla es un tablero, no una distracción. El teléfono queda quieto sobre la mesa. Los gráficos son simples y estables, para que la atención esté en las tapitas.
- Grupos que se pueden tocar. Cada dado tiene su color y cada color es un grupo de tapitas. Así, sumar es juntar dos grupos reales.
- Sesiones cortas y predecibles. Ella elige 3, 5 u 8 rondas. Antes del final suena un aviso, para que el cierre no la tome por sorpresa.
- El final se siente como final. El botón para volver a jugar tarda 4 segundos en aparecer. Esa pausa evita que siga tocando sin parar.
- Respeta lo que hay en la mesa. El juego nunca pide más tapitas de las que caben en su caja.
- Calma visual. Hay muy pocas animaciones (ninguna si el teléfono tiene activada la reducción de movimiento), cada operación tiene siempre el mismo color y las frases son cortas, en español neutro.
2. La investigación: qué necesita un niño para aprender a contar
Antes de escribir código, conviene entender qué estamos enseñando. No hace falta ser especialista; alcanza con unos pocos conceptos que la educación matemática temprana usa desde hace décadas.
Nota: esta sección resume conceptos generales para explicar las decisiones de diseño. No es una guía clínica. Si trabajas con un niño con necesidades específicas, la palabra de su terapeuta y de su familia está por encima de cualquier app.
Contar no es recitar
Un niño puede decir "uno, dos, tres, cuatro" de memoria y no saber contar. Los psicólogos Rochel Gelman y C. R. Gallistel describieron en 1978 los principios que hay detrás de contar de verdad. Tres de ellos guían este juego:
| Principio | Qué significa | Cómo lo aplica el juego |
|---|---|---|
| Correspondencia uno a uno | Cada objeto se cuenta una sola vez. | Cada tapita tocada muestra su número y, si se toca de nuevo, repite el mismo número. |
| Orden estable | Los números se dicen siempre en el mismo orden. | La voz dice "uno, dos, tres…" con cada toque. |
| Cardinalidad | El último número dicho es cuántos hay. | Al terminar la ayuda, la voz dice "Hay cuatro." y la respuesta correcta empieza a latir. |
De lo concreto a lo abstracto
Un enfoque muy usado para enseñar matemáticas es concreto → pictórico → abstracto: primero objetos reales, después dibujos de esos objetos, y recién al final los símbolos (2 + 1 = 3). El juego tiene los tres en la misma pantalla:
- Concreto: las tapitas reales en la mesa (modo "Tapitas reales").
- Pictórico: las tapitas dibujadas y los puntos de los dados.
- Abstracto: la frase "2 más 1 es igual a…" y los números de las respuestas.
Ver cantidades sin contar
Los adultos vemos tres puntos y sabemos que son tres sin contarlos. Esa capacidad se llama subitización y funciona con cantidades chicas. Por eso:
- Los dados se dibujan con sus puntos de siempre, no con números.
- Debajo de cada respuesta hay tantos puntitos como vale.
- Las tapitas se ordenan en filas de 5, como un cartón de huevos: es más fácil ver "una fila llena y dos más" que un montón desordenado.
Previsibilidad y transiciones
Para muchos niños, y en especial para muchos niños autistas, lo inesperado es difícil: el final abrupto de una actividad, un sonido fuerte, un cambio de colores. De ahí salen varias reglas:
- Ancla visual: cada operación tiene su color fijo (Contar azul, Sumar naranja…) y la mascota es siempre la misma tapita roja.
- Anticipar el final: dos rondas antes de terminar la voz dice "Dos más y guardamos", y en la última, "La última y guardamos".
- Cierre: la pantalla final dice "Ahora guardamos las tapitas juntos" y los botones aparecen después de 4 segundos. Así el cierre no se convierte en "una más, una más".
Atención y error
Con TDAH, cada estímulo compite por la atención. Por eso el juego tiene pocas animaciones, sin premios que distraigan, y un error no se castiga: suena un tono grave y suave, la app cuenta en voz alta con el niño y la respuesta correcta late. El error se convierte en una oportunidad de contar juntos.
El adulto es parte del juego
El juego está pensado para jugar con un adulto al lado. En la pantalla hay una sección "Para el adulto" con consejos ("Cuenta tú primero, despacio…", "Si elige mal, no digas «no»…") y un modo en el que la pantalla solo muestra los dados y le dice al adulto qué sacar de la caja.
3. Decisiones técnicas: por qué sin framework
El juego tiene 3 pantallas y como mucho unas 60 tapitas en pantalla. Para eso no hace falta React, Vue ni un bundler. Usar HTML, CSS y JavaScript "a secas" tiene ventajas reales:
- Lo que publicas son exactamente los archivos que escribiste. No hay paso de compilación que pueda fallar.
- Cualquier persona que esté empezando puede leer el código de arriba a abajo.
- Los navegadores modernos ya traen todo lo que necesitamos: módulos (
import/export), voz (Web Speech API), sonido (Web Audio API) y almacenamiento (localStorage).
La arquitectura en una imagen
clic del niño │ ▼ main.js ── busca el data-action del botón ──▶ game.js (acción) │ cambia `state` y llama a notify() │ ▼ render.js dibuja la pantalla Cada archivo tiene una sola responsabilidad:
| Archivo | Qué hace | Qué NO hace |
|---|---|---|
rules.js | Recibe ajustes y devuelve una ronda. | No toca el DOM, no suena. |
game.js | Guarda el estado y tiene las acciones. | No arma HTML. |
render.js | Lee el estado y dibuja. | No cambia el estado. |
main.js | Escucha los clics y llama a las acciones. | No tiene lógica del juego. |
La regla más importante: la información fluye en una sola dirección. Si algo se ve mal, el problema está en render.js. Si el juego hace algo que no debería, está en game.js o en rules.js. Es la misma idea que usan React o Redux, pero sin la librería.
Paso 0: carpetas y servidor local
Crea esta estructura:
matematicas-con-dados/ ├── index.html ├── css/ │ └── styles.css ├── js/ │ ├── audio.js voz y sonidos │ ├── config.js datos fijos: colores, tiempos │ ├── game.js estado y acciones │ ├── main.js punto de entrada │ ├── render.js dibuja la pantalla │ ├── rules.js reglas: arma cada ronda │ └── spanish.js números en palabras ├── tests/ │ └── rules.test.js └── package.json En package.json solo necesitamos decirle a Node que usamos módulos:
{ "name": "matematicas-con-dados", "type": "module", "private": true } Por qué no basta con abrir el archivo con doble clic
Vamos a usar módulos de JavaScript (<script type="module">). Por seguridad, los navegadores no cargan módulos desde file://. Necesitas un servidor local. Cualquiera de estos sirve:
python3 -m http.server 3000 # si tienes Python o la extensión Live Server de VS Code. Luego abre http://localhost:3000.
En el proyecto real hay un pequeño servidor en Node (
scripts/serve.js) que además aplica las mismas cabeceras de seguridad que el sitio publicado. Para este tutorial no hace falta.
Paso 1: el HTML de las tres pantallas
El juego tiene tres pantallas: inicio, juego y final. Las tres están en el mismo HTML y se muestran u ocultan con el atributo hidden. Las zonas que cambian (tarjetas, dados, mesa, respuestas) empiezan vacías: las llena JavaScript.
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"> <!-- INICIO --> <section id="screen-home" class="screen"> <h1>Matemáticas con Dados</h1> <div id="operation-tiles" class="tiles"></div> </section> <!-- JUEGO --> <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> <!-- FINAL --> <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> Tres detalles:
-
lang="es"hace que los lectores de pantalla lean en español. -
type="module"permite usarimport/exporty hace que el script se ejecute cuando el HTML ya cargó. No hace falta esperar aDOMContentLoaded. - Ningún botón tiene
onclick. Llevandata-action="..."y, en el paso 7, un solo listener se encarga de todos.
Paso 2: la configuración
Todo lo que es "dato fijo" va en un solo archivo. Si mañana quieres cambiar un color o un tiempo, sabes dónde buscar.
js/config.js:
/** * CONFIGURACIÓN: todos los datos fijos del juego. * Si quieres cambiar un color o un tiempo, este es el archivo. */ /** Cada operación tiene SIEMPRE el mismo color: es el ancla visual del niño. */ export const OPERATIONS = { contar: { name: 'Contar', symbol: '', color: '#4f8ef7' }, sumar: { name: 'Sumar', symbol: '+', color: '#ff7a4d' }, }; /** Colores de las tapitas, y el color fijo de cada dado (dado 1 azul, dado 2 verde...). */ export const CAP_COLORS = { blue: '#1d4ed8', green: '#15803d', red: '#b91c1c', orange: '#c2410c' }; export const DICE_COLORS = ['blue', 'green', 'red', 'orange']; /** Tiempos, en milisegundos. */ export const TIMING = { nextRoundDelay: 2800, // después de acertar demoStepDelay: 700, // al contar una tapita en la ayuda endButtonsDelay: 4000, // los botones del final aparecen después }; export function defaultSettings() { return { operation: 'sumar', dice: 2, // cuántos dados faces: 3, // caras del dado: 1 a 3 rounds: 5, // rondas por sesión }; } ¿Por qué defaultSettings() es una función y no una constante? Porque cada vez que la llamas devuelve un objeto nuevo. Si fuera una constante compartida, cambiar los ajustes en un lugar los cambiaría en todos.
Y un ayudante para que la voz diga los números en palabras, js/spanish.js:
// prettier-ignore const NUMBER_WORDS = [ 'cero', 'uno', 'dos', 'tres', 'cuatro', 'cinco', 'seis', 'siete', 'ocho', 'nueve', 'diez', 'once', 'doce', ]; /** 3 → "tres". Desde el 13 usa la cifra, que los motores de voz leen bien. */ export function numberWord(n) { return n >= 0 && n < NUMBER_WORDS.length ? NUMBER_WORDS[n] : String(n); } Paso 3: las reglas del juego (lógica pura)
Este es el corazón del juego y el archivo más importante del tutorial. Su trabajo: recibir los ajustes y devolver una ronda. Una ronda es un objeto así:
{ operation: 'sumar', values: [2, 1], // lo que salió en cada dado groups: [['blue', 'blue'], ['green']], // las tapitas, agrupadas, con su color answer: 3, prompt: '2 más 1 es igual a…', // lo que se lee en pantalla speech: 'dos más uno es igual a…', // lo que dice la voz key: '2,1', // identifica la tirada, para no repetirla } Fíjate en que la ronda no sabe nada de botones ni de pantallas. Es solo datos. Eso es lo que llamamos "lógica pura": una función que recibe datos y devuelve datos, sin tocar el DOM, sin sonar y sin guardar nada.
js/rules.js:
/** * REGLAS DEL JUEGO: lógica pura. * Recibe ajustes, devuelve una ronda. No toca el DOM, no suena, no guarda. * Por eso se puede probar en Node, sin navegador. */ import { DICE_COLORS } from './config.js'; import { numberWord } from './spanish.js'; /** Entero al azar entre min y max, ambos incluidos. */ 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…": en pantalla con cifras (String), en la voz con palabras (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, // Un solo montón, del color del primer dado: no hay grupos que juntar, solo contar. 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, // Un grupo por dado, del color de su dado. 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 }; /** * Crea la siguiente ronda, sin repetir la tirada anterior. * @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; } /** Tres respuestas distintas, de menor a mayor, con la correcta incluida. */ export function makeAnswerOptions(answer, random = Math.random) { const nearby = [answer - 2, answer - 1, answer + 1, answer + 2].filter((n) => n >= 0); // Mezcla de Fisher-Yates 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." */ export function resultPhrase(answer) { return answer === 1 ? 'Hay una.' : `Hay ${numberWord(answer)}.`; } Cuatro ideas para llevarte de este archivo:
- Una función por operación y un objeto que las agrupa (
BUILDERS). Para agregar Restar no hay que tocarcreateRound: escribesbuildRestary la sumas aBUILDERS. - El azar se puede reemplazar. Todas las funciones reciben
randomcomo parámetro, conMath.randompor defecto. En las pruebas le pasamos un generador predecible y así una prueba que falla, falla siempre igual. - Respuestas cercanas. Las opciones incorrectas están a 1 o 2 de distancia de la correcta. Si una fuera 3 y la otra 50, el niño adivinaría sin contar.
- No repetir la tirada anterior. Si sale dos veces seguidas "2 más 1", para el niño parece que no pasó nada.
keyidentifica la tirada ycreateRoundvuelve a tirar si se repite.
La frase "es igual a…". Al principio el juego decía “Juntamos. ¿Cuántas hay?”. Un día, viendo con mi hija Numberblocks, su programa favorito, me di cuenta de que convenía decir la operación como se dice ahí: “2 más 1 es igual a…”. Así escucha desde el principio el mismo lenguaje que ya conoce del programa y que va a usar en la escuela. Fíjate en que la voz dice “uno” y no “una”: hablamos de números, no de tapitas.
Paso 4: probar las reglas antes de tener pantalla
Como rules.js es lógica pura, la podemos probar en la terminal antes de dibujar un solo botón. Node trae su propio sistema de pruebas (node:test), así que no hay que instalar nada.
tests/rules.test.js:
import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createRound, makeAnswerOptions } from '../js/rules.js'; /** Números al azar repetibles: si una prueba falla, falla siempre igual. */ 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');Source:https://dev.to/enlabe/matematicas-con-dados-como-construi-un-juego-para-que-mi-nina-aprenda-a-contar-con-dados-y-tapitas-6e1Related posts
A marketplace comparison should explain what its price includes
Filtering Clients Is the Hidden Time-Killer for Freelancers
Mastering INP: How We Achieved Sub-10ms Input Latency Across 30 Reactive Web Tools
![Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q4w48b9bzs43tynyhsf.png)
Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It
Adding AdSense to a static Vercel site without throwing away your Content-Security-Policy
