A first chess app for a four-year-old. Runs as a web page, installs to a tablet home screen, works offline, no accounts, no ads, no network calls of any kind.
Double-click drinachess-single-file.html. That's it. Everything
is squashed into that one file, so it runs straight off your disk in
any browser, on Windows, Mac or a tablet. Nothing to install.
It's built from the real source by node tools/bundle.mjs, so rebuild
it whenever you want a fresh standalone copy. Two things it can't do:
no offline caching (service workers need https or localhost), and no
external audio or artwork files, since file:// can't fetch them — so
every line uses the built-in voice. Fine for trying things out, not
the version to develop against.
Only needed once you're editing files or dropping in recordings.
index.html can't just be double-clicked, because browsers refuse to
load ES modules over file://. You need any local web server:
node tools/serve.mjs # or: npm run serveNo dependencies — it's Node's standard library only. If you don't have Node, any of these work just as well:
python -m http.server 8000 # Windows, if Python is installed
python3 -m http.server 8000 # Mac / Linux
npx --yes serve # if you have Node but prefer thisThen open http://localhost:8000.
If you're on Windows and none of those work, you almost certainly just don't have Node or Python installed, which is completely normal — neither ships with Windows. You don't need either one: use the single-file version above for playing, and push to GitHub Pages for the real thing. VS Code's "Live Server" extension is also a good one-click option if you already use VS Code.
Node is only genuinely required for the three helper scripts —
tools/test.mjs, tools/make-script.mjs and tools/bundle.mjs — and
none of them are needed to play or to deploy.
The whole point of the GitHub Pages route: once it's up, the tablet installs it like a real app.
- Deploy to Pages (below). You need nothing installed to do this — GitHub's website takes a drag-and-drop upload.
- On the tablet, open Chrome and go to your Pages URL.
- Chrome menu (⋮) → Install app (it may say Add to Home screen). Chrome offers the proper install because the app ships a manifest and a service worker.
- It lands on the home screen with the pink knight icon and opens fullscreen — no address bar, no tabs, no way to wander off into the internet.
It works offline after that first visit. The service worker caches the app on load, and each voice clip caches the first time it plays. Turn the wifi off and it keeps working.
Screen pinning is the feature you actually want. Android can lock the tablet to one app until an adult unlocks it, which stops a four-year-old tapping her way into your email. Settings → Security → Screen pinning (sometimes under Advanced or Other security settings), turn it on with "ask for PIN before unpinning", then open the app, hit the Recents button, and tap the pin icon on its card.
If it's silent, the tablet is missing a text-to-speech voice. Settings → Accessibility → Text-to-speech output — install Google Speech Services and pick an English voice. Your own recordings don't need any of this; it only affects the lines you haven't recorded.
iPad, for what it's worth: identical, except it's Safari and the menu item is Share → Add to Home Screen. Chrome on iOS can't install web apps, so it has to be Safari there.
The account is LucifersStepdad, so a repo called drinachess puts
the app at:
https://lucifersstepdad.github.io/drinachess/
- Go to github.com/new. Name it
drinachess, set it Public (Pages is free on public repos), and don't add a README — the repo must start empty. Create it. - On the empty repo page click uploading an existing file.
- Open this folder, select everything inside it —
index.html,css,js,art,audio,music,manifest.json,sw.js,.nojekylland the rest — and drag it into the browser. Drag the contents, not the folder. Commit. - Settings → Pages → Source: Deploy from a branch →
main→/ (root)→ Save. Give it a minute or two on the first deploy.
Windows hides dotfiles by default, so .nojekyll may not appear in the
drag. Turn on View → Hidden items in Explorer first. It's a
belt-and-braces file — Pages only ignores folders starting with _,
and there aren't any — but it costs nothing to include.
The repo here is already committed with main as its branch, so:
git remote add origin https://github.com/LucifersStepdad/drinachess.git
git push -u origin mainThen set Pages as in step 4 above. Windows will pop up a browser to sign in to GitHub the first time.
Push, wait a minute, reload. The service worker version now looks
after itself — tools/bundle.mjs restamps it from a hash of the
files it caches, so the moment anything changes the old cache is
thrown away on the next load. Just make sure you've run:
node tools/bundle.mjs # or: npm run bundlebefore you push, and the stamp will be current. (If you ever edit files
without rebuilding, npm run stamp does the version on its own.)
Every path in the project is relative — ./js/main.js, never
/js/main.js — because project Pages serve from /<repo>/ rather than
the domain root. This is checked rather than hoped for: the app has been
run from a simulated /drinachess/ subpath with the service worker
registering at the right scope, a level played through and a reload
surviving. If you add files, keep them relative or they'll 404 in
production while working perfectly on localhost.
Once it's up, open the Pages URL in Chrome on the tablet and use ⋮ → Install app. It lands on the home screen and works offline from then on. Full instructions are further up under Putting it on an Android tablet.
Free GitHub Pages repos are public. There's no data collection here so it's low stakes, but two things are worth a conscious decision rather than a default:
childNameinjs/config.jsis blank. Set it and her name gets spoken in the app and committed to a public repo. The voice lines are written so the name token disappears cleanly if you leave it blank — "Nice one, {name}!" just becomes "Nice one!".- Your recordings are also public files. That's probably fine, but it's your voice on the open internet, so decide on purpose.
If either bothers you, a private repo with Pages enabled needs a paid plan; alternatively Netlify and Cloudflare Pages both host private repos free and work identically here.
A four-year-old cannot learn chess as a whole — there's too much of it, and none of the pieces make sense in isolation. So the app never presents "chess". It presents a ladder of one-idea games, and chess turns out to be what's at the top of it.
60 levels in 8 chapters. Nothing is locked — every level is available from the first launch. She picks whatever looks good, and finished levels get a star so she can see where she's been.
| Chapter | Levels | What it teaches |
|---|---|---|
| The Rook | 8 | Straight lines. |
| The Bishop | 8 | Diagonals, and being stuck on one colour forever. |
| The Knight | 9 | The L. More levels than anything else, because it needs them. |
| The Queen | 7 | She does everything the others do. |
| The King | 6 | One square at a time. |
| The Pawn | 7 | Walks straight, eats diagonally, and becomes a queen. |
| Teamwork | 8 | Two or three of her pieces at once. |
| Real Games | 7 | Pawn Wars, four battle setups, real chess, two-player. |
Each piece chapter follows the same arc: reach one star, reach several, eat something, eat several, find a way round a wall, then sneak past something watching. That last one is the important one — see below.
Pawn Wars is the other one that matters. It's a complete strategic game with one rule, it's genuinely fun, and it's where most kids first experience thinking ahead rather than following a rule. Don't be surprised if she lives there for a month.
Play Daddy takes the grown-up's name from grownupName in
js/config.js — level titles may contain {grownup} and it is filled
in when the card is drawn.
The board turns round between turns. Pass-the-tablet chess means one of the two players is always reading the board upside down, which is genuinely hard. On the grown-up's turn the whole board flips.
It's done by mapping board square to screen cell (63 - i, a half
turn) rather than with a CSS rotation. A rotation would need every
glyph counter-rotated, would fight the pop and wobble animations for
control of transform, and would leave taps and sparkle positions to
be un-rotated by hand. Swapping the index is one line and everything
downstream — hit testing, animations, effects — stays honest. It also
leaves the square colours alone, since flipping row r to 7 - r and
column c to 7 - c doesn't change the parity of r + c.
Two mechanics beyond "here's how the piece moves":
Walls (# in a layout) are impassable. Nothing lands on one and
nothing slides through one — except the knight, which jumps clean
over, and there's a level that exists purely to make that land.
Watched squares (guarded: true on a level) are every square an
enemy piece is attacking. They're shaded, and she can't move onto
one. She can slide through one, exactly as in real chess. This is
the single most valuable idea in the app: it's the first time the
enemy's position changes what she's allowed to do, and it's the whole
concept of piece safety years before she'll hear the word "check".
Careful, King! is literally check avoidance with the jargon removed.
Pawn Wars is the important one. It's a complete strategic game with one rule, it's genuinely fun, and it's where most kids first experience thinking ahead rather than following a rule. Don't be surprised if she lives there for a month.
Both are fully implemented and always were — js/rules.js handles
castling rights, castling through check, the rook's jump, the en
passant square and its one-move expiry, and tools/test.mjs checks all
of it against Kiwipete, the standard position for catching exactly
these bugs.
What was missing was anywhere to learn them, so the Clever Moves
chapter teaches one each. Both use a goal type where the move itself is
the win — goal: 'castle' and goal: 'en-passant' — because a level
that ends the instant she plays the move is the only unambiguous way to
teach a specific move.
Two wrinkles worth knowing about:
En passant can't be expressed by a board alone. It depends on the
move that was just played, and a layout has no history. So the layout
gets a ^ marking the square the enemy pawn skipped over, and the game
seeds state.enPassant from it. The level opens as though he had just
run past her, which is the only moment the rule exists.
Both chances can be thrown away in one tap. Castling rights die the
moment the king or rook moves; an en passant chance lasts exactly one
move. On a level whose entire point is that move, a stray tap would
leave her on a board that can never be finished, with no way to know
why. So Game.rescueIfSpoiled() quietly puts the position back and
says a line about setting the pieces up again. It is never a failure
sound and never a shake — it's an adult saying "hang on, let's put that
back". tools/test.mjs proves that every move which spoils one of
these levels trips the rescue.
Little Battle now has its kings on the e-file with both rooks in the corners, so it's the natural place to practise castling in a real game. It used to keep them on the d-file specifically so castling could never come up before she'd been taught it.
Every "win" level ends when the enemy king is captured, not checkmated. This is deliberate and it's what actual children's chess teaching does: checkmate requires understanding check, pins, legal move filtering and king safety all at once, and it's years away. King capture gives her a goal she can see and chase from day one.
The full rules are all implemented, so switching later is easy —
legalMoves(state, colour, true) in js/rules.js filters out moves
that leave your own king in check, which is real chess. Nothing else
needs to change.
A pawn cannot turn round. On a pawn level that means one careless push can leave her with nothing legal to play and a level that can never be finished — and a four-year-old tapping a board that has stopped responding has no way to tell a dead end from a broken game.
Game.rescueIfStuck() runs after each of her moves on a solo level. If
she has nothing playable left, the move is taken back and the voice says
so. It keeps stepping further back while the position it lands on offers
no real choice, because undoing a move that was forced anyway would just
hand her the same dead end again. It uses the undo sound, never a
failure one.
Note that "playable" is not the same as "legal": on a Stay Safe level the watched squares are legal chess but forbidden, so a piece with nothing but shadowed squares to go to counts as stuck.
tools/test.mjs walks every position reachable in every solo level and
lists the ones containing a dead end — currently Pawn Snack, Staircase
and Pawn Maze, one each. They aren't failures, since the rescue handles
them, but the listing means a newly added level that can strand her
shows up in the test output rather than on a tablet. What it does
assert is that any level with a dead end offers more than one move at
the start, so the rescue always has somewhere worth putting her.
Tactics: the first time chess stops being "how do the pieces move" and becomes "I saw something he didn't". Ten levels covering forks, skewers, a pin, discovered attacks and royal forks.
Every one is two moves — play the trick, watch him save what he can, take the rest — and the second move is the point, because it is the proof that the first one worked. Winning means capturing something worth 3 or more, or the king. Pawns deliberately don't count, or a level could be finished by munching a defender and missing the idea entirely.
The opponent is not Sir Blunders. chooseFleeMove() is a defender
that does exactly one thing: nothing at all until something of his is
attacked, and then it saves the most valuable piece it can. It never
captures, never develops, never wanders. Anything else would move the
board around underneath a four-year-old mid-thought. When nothing is
threatened it returns null and the turn goes straight back to her, so
she can sit and look for as long as she likes.
Two details in there matter more than they look:
- It weighs the king above everything, not at
PIECE_VALUE.K, which is 0. Otherwise a pinned knight would happily step aside and hand over the king — which is to say, pins would not work. - It only counts problems its move creates. Judging an escape by "is anything still hanging afterwards" freezes it completely in a fork, since by definition one piece cannot be saved. It would sit still and lose both, which is the exact opposite of the lesson.
The positions are checked, not eyeballed. tools/test.mjs plays
each one against the real flee opponent and requires: no capture worth
having on move one, a fastest win of exactly two moves, and exactly one
first move that achieves it. That last condition is the one that earns
its keep — three of the ten first drafts could be solved by a rook
strolling to an empty back rank in the same two moves, which teaches
nothing. The stray pawns in the skewer levels are there to close those
routes, not for decoration.
The bot is the part with the most thought in it, because random is the obvious answer and it's the wrong one. A random-legal-move bot blunders constantly but doesn't reliably lose, and — much worse — it can stumble into checkmating a beginner who's left her king in the open. An app that occasionally beats her is a broken app.
So js/bot.js runs a one-ply material evaluation with the sign
flipped: it looks at every legal move and plays one of the worst. On
top of that, four guardrails:
- It never captures her king. Not "rarely" — the move is filtered out of the list before anything else happens.
- It won't race in Pawn Wars. A deliberately-terrible bot that accidentally promotes and wins the race would be the single worst bug in the app, so any move landing a pawn near its promotion rank is removed unless it has literally nothing else.
- It avoids giving check when it has any other option. Check is confusing before you understand it and isn't needed for king-capture rules.
- A lone king walks towards her pieces instead of running away, so she can actually finish the game. Chasing a fleeing king around an empty board is beyond a four-year-old and would just end in tears.
And it's paced. It plays merely-poor for the first few moves and gets worse from there, because hanging the queen on move three ends the game before she's had any fun. It also actively prefers moves that leave a piece capturable, which is what turns "plays badly" into "keeps offering her free pieces" — much more fun to play against.
The knobs are all near the top of bot.js: blunderiness() controls
the ramp. If she outgrows him, raising the floor from 0.45 and
lowering the 0.92 cap makes him meaningfully harder without touching
anything else.
Speech is asynchronous — say() may be waiting on a recording to load,
or on a whole line to finish — and a four-year-old tapping quickly will
have moved on twice before any of that resolves. Three things keep the
audio from stacking up, and all three are needed:
-
audio.jskeeps agenerationcounter, bumped by everystopSpeaking(). Eachsay()grabs the number when it starts and re-checks it after every wait; a line that has been overtaken stops instead of talking over its replacement. This matters becausespeechSynthesis.speak()queues rather than replaces, so a stale caller pushing one more utterance is exactly how a backlog forms. -
Game.voice()wraps every line the game speaks, and an abandonedGamerefuses to speak at all. Without it, the intro of a level she left carries on to announce that level's goal over the top of the one she just opened — theawaitinstart()resolves the moment the intro is cancelled, and the next line goes out regardless. -
Nothing speaks from a bare timer. The "pick a game!" nudge checks that she is still looking at the chapter list before saying anything.
There is also an explicit releaseWait, because neither
speechSynthesis.cancel() nor pausing an <audio> element reliably
ends the promise say() is parked on. Without it an interrupted
recording — not the fallback voice, the real MP3 — would leave say()
awaiting forever and isSpeaking() stuck true for the rest of the
session. That one only bites once you have actually recorded something,
which is why it is easy to miss.
audio/RECORDING-SCRIPT.md is the checklist. It's generated from
js/voice.js, so it can't drift from what the app actually says. If
you add or change a line, run:
node tools/make-script.mjsSave each clip as an MP3 in audio/ with the filename from the
checklist — good_move_1.mp3, rook_intro_2.mp3 and so on. Drop it
in and it's live. There is no list to update and no code to change.
The app tries your file first and falls back to the tablet's built-in
voice for anything you haven't recorded yet, which means:
- it's fully playable and fully spoken before you record anything
- you can record one line at a time over months
- a missing file is never a bug
104 clips sounds like a lot. It isn't — most are one-second reactions ("Yes!", "Munch!", "Star!"), and the app feels finished after about twenty. The order to record them in is at the top of the script.
The one piece of advice worth taking: record at least five variants
of good_move, and change your energy between takes. It's the
most-played line in the app by a wide margin, and identical repetition
is what makes children's software unbearable — for her, and much
sooner, for you.
Drop a looping track at music/theme.mp3. It plays quietly and ducks
automatically whenever anyone speaks. Make sure it loops seamlessly —
a small gap at the loop point is inaudible once and excruciating on
the hundredth pass.
Everything you're likely to want to change is in one of three files:
js/config.js — her name, what she calls you, the bot's name and
face, volumes, how long the bot "thinks", your handicap. Start here.
js/levels.js — the levels, written as eight strings you can
edit by eye:
{
id: 'rook-5', chapter: 'rook', title: 'Round the Wall',
emoji: '🧱', teaches: 'R',
goal: 'stars', opponent: null,
layout: [
'........', // UPPERCASE = her pieces (white, move up)
'........', // lowercase = the enemy's
'..*.....', // * = a star to collect
'..###...', // # = a wall
'........', // . = empty
'........',
'........',
'..R.....',
],
},Add, delete and reorder freely — nothing refers to levels by position,
only by id. Set chapter to one of the ids in CHAPTERS at the top
of the file, and it appears there automatically.
Three things to watch, all of which npm test checks for you:
- A bishop level's stars must sit on the same colour square as the bishop, or they're unreachable and the level can't be finished.
- Don't start her piece on a watched square in a
guardedlevel — it's baffling, and it's an easy mistake to make. - A king on the e-file makes castling available. All the battle levels deliberately put kings on the d-file to avoid it.
Every non-bot level is brute-force solved by the test suite. A
hand-authored layout that turns out to be impossible is exactly the
kind of bug that would otherwise reach her — a wall sealing off the
only route, a star on the wrong colour, a guard covering every
crossing. Single-piece levels get an exhaustive search; multi-piece
ones get a reachability analysis per piece. If you add a level, run
npm test before she sees it.
js/voice.js — every spoken line, with a note on when each plays.
Every colour in the app comes from the :root block at the top of
css/style.css, so you can retheme the whole thing by editing that
one block and touching nothing else. A pastel light variant is sitting
commented out just underneath it if you want the candy-shop version
instead of the deep one.
The stars aren't images — they're the ★ character with CSS on top,
so --star recolours every one of them. Same for the pieces: they're
Unicode glyphs, coloured by --piece-white and --piece-black.
The home-screen icons are generated separately. If you change the
palette, update the two colours at the top of tools/make-icons.py
and re-run it (this one does need Python; the icons in art/ are
already committed, so you only need to if you're recolouring).
The four board signals — selected piece, somewhere you can move, something you can take, a star — deliberately differ in both colour and shape. Worth preserving if you retheme: in a palette this monochromatic, shape is what keeps them apart.
All of it lives in js/sparkle.js and the matching CSS:
- Ambient glitter —
ambientSparkles($('#sparkles'))inmain.jstakes a count, default 20. Drop it to 8 for calmer, push it to 40 for more. Speed and twinkle rate are the two animation durations set inside the function. - The confetti cannon —
confettiCannon(host, count), default 40 pieces.CONFETTIat the top of the file is the list of things that fly out; add whatever she's into this month. - The aurora behind everything is
#aurorain the CSS; its colours are the four radial gradients, its pace isaurora-drift. - The halo around the board is
.board-wrap::before.
Note that none of it touches the board itself, which is deliberate — the decoration lives behind and around the playing surface so it never competes with the thing she's meant to be looking at. The one exception is the small burst when she collects a star, which is feedback rather than decoration.
- Tap-to-select, tap-to-move. Never drag. Small hands are bad at drag-and-drop, and tapping makes a mis-drop impossible — the worst outcome is selecting a different piece.
- No reading required. She's four. Every label is backed by a glyph and a spoken line; nothing is text-only.
- No fail state, anywhere. No timers, no score, no losing screen, no red. A wrong tap gets a soft wobble and the rule restated in a cheerful voice. Undo is always available and takes back her move and the bot's reply together.
- Nothing is locked. No gates, no "finish this first". She can open Real Chess on day one if she wants to, and bounce off it, and come back in six months.
- No dead ends either. The bot will not take her last piece, and if the board somehow reaches a position where she can't move, the level ends kindly instead of quietly ignoring her taps.
- A hint after 22 seconds of nothing. Long enough not to nag.
- Auto-queen on promotion. She doesn't need to be offered underpromotion, and "your pawn is a QUEEN now" is one of the best moments in the app.
Press and hold the cog on the chapter screen for about a second. Held, not tapped, so she doesn't find it by accident. Inside: reset progress, and your handicap for Real Chess and two-player (no queen, or no queen and no rooks). Worth using — an even game against a parent is not an even game.
index.html screens
css/style.css all styling
js/config.js ← personalise here
js/levels.js ← the level ladder
js/voice.js ← every spoken line
js/levels-hanging.js the Stay Safe chapter — GENERATED, don't hand-edit
js/rules.js chess move generation
js/bot.js Sir Blunders
js/board.js rendering and taps
js/game.js one level being played
js/audio.js speech, music, effects
js/main.js screens, progress, boot
sw.js offline caching ← bump VERSION on deploy
audio/ your recordings + the generated script
art/ icons, and optional piece artwork
drinachess-single-file.html ← double-click this to just play
tools/serve.mjs local web server, no dependencies
tools/test.mjs rules + bot test suite
tools/layout-test.mjs checks it fits on real phone/tablet screens
tools/make-script.mjs regenerates the recording checklist
tools/bundle.mjs rebuilds the single-file version
tools/make-hanging-levels.mjs regenerates the Stay Safe chapter
tools/make-icons.py regenerates the home-screen icons
Forty-two levels on one idea: look at what the enemy is watching before you move. Every square an enemy piece can reach is shaded, and she is not allowed to land on one. She cannot lose a piece here — the move is simply refused and the voice explains why, which keeps the "nothing in this app is a failure" rule intact while still drilling the habit. Putting her queen somewhere unsafe gets its own, louder line.
The chapter ramps from one watcher to four overlapping ones, and cycles evenly through all six of her pieces, so the lesson doesn't attach itself to any one of them.
It's generated, not hand-written:
node tools/make-hanging-levels.mjs # 42 levels
node tools/make-hanging-levels.mjs 80 # more
node tools/make-hanging-levels.mjs 42 7 # a different set, seed 7Layouts are random, but every one is run through the real rules engine and thrown away unless it is solvable by exhaustive search, takes at least two moves, has at least one tempting move from the starting square that lands in the shadow, and can't be short-circuited by eating a watcher on move one. About one candidate in eighty survives. The seed is fixed, so re-running gives you the same levels back and doesn't reshuffle a chapter she has already starred.
Worth knowing before you touch the layout, because it went wrong twice and both times it looked perfect on a laptop.
Nothing sizes itself from vh. On a phone, 100vh is the height
of the screen with the browser's URL bar retracted — which is more
than you can actually see. Size a full-height layout from it and the
bottom sits underneath the browser chrome, and because the page is
fixed with overflow: hidden, there's no way to scroll down to it.
It looks, in a word, broken.
So .board-wrap takes whatever's left after the bar and the banner
via plain flexbox, and a ResizeObserver in board.js measures that
box and publishes two custom properties: --board-px (the square's
side) and --sq (one cell). Pieces and stars are sized from --sq.
Measuring the box that exists can't be wrong, whatever the browser
believes the viewport to be, and it survives rotation for free.
The other trap was align-content: center on the chapter grid. When
the cards overflow — which they do on any phone — centring pushes the
top rows above the scroll container, where nothing can scroll to
them. Two of the eight chapters were invisible on a phone. Use
start in any scrolling grid.
npm run layout checks both, at six screen sizes, all deliberately
shorter than the devices they name so the browser toolbar is
accounted for. It needs Playwright (npm i -g playwright); the main
npm test still needs nothing.
The obvious move is chess.js, and for the full game it would be
fine. It doesn't work for the teaching levels: every chess library
requires a legal position, and "one rook and a star on an otherwise
empty board" has no kings in it, so libraries reject the position
outright. js/rules.js is about 300 lines and has no such opinion.
It's complete, though — castling, en passant, promotion, and a strict
mode that filters moves leaving your own king in check, for whenever
you want real checkmate rules.