Skip to content

About

Teaching Drina Chess

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

DrinaChess

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.


Just play it — no install, nothing to set up

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.

Run the real thing locally

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 serve

No 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 this

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

Putting it on an Android tablet

The whole point of the GitHub Pages route: once it's up, the tablet installs it like a real app.

  1. Deploy to Pages (below). You need nothing installed to do this — GitHub's website takes a drag-and-drop upload.
  2. On the tablet, open Chrome and go to your Pages URL.
  3. 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.
  4. 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.

Deploy to GitHub Pages

The account is LucifersStepdad, so a repo called drinachess puts the app at:

https://lucifersstepdad.github.io/drinachess/

The no-tools way (nothing to install)

  1. 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.
  2. On the empty repo page click uploading an existing file.
  3. Open this folder, select everything inside it — index.html, css, js, art, audio, music, manifest.json, sw.js, .nojekyll and the rest — and drag it into the browser. Drag the contents, not the folder. Commit.
  4. 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 git way

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 main

Then set Pages as in step 4 above. Windows will pop up a browser to sign in to GitHub the first time.

Redeploying

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 bundle

before you push, and the stamp will be current. (If you ever edit files without rebuilding, npm run stamp does the version on its own.)

It really does work from a subdirectory

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.

The tablet

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.

A note on the public repo

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:

  • childName in js/config.js is 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.


How it teaches

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.

Two players, one tablet

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.


Walls and watched squares

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.

Castling and en passant

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.


Catching the king, not checkmate

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.


Dead ends, and taking the move back

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.


The Tricks chapter

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.


Sir Blunders

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:

  1. It never captures her king. Not "rarely" — the move is filtered out of the list before anything else happens.
  2. 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.
  3. 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.
  4. 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.


One voice at a time

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:

  1. audio.js keeps a generation counter, bumped by every stopSpeaking(). Each say() 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 because speechSynthesis.speak() queues rather than replaces, so a stale caller pushing one more utterance is exactly how a backlog forms.

  2. Game.voice() wraps every line the game speaks, and an abandoned Game refuses 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 — the await in start() resolves the moment the intro is cancelled, and the next line goes out regardless.

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


Recording your voice lines

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

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

Music

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.


Making changes

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 guarded level — 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.

Changing the colours

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.

Turning the sparkle up or down

All of it lives in js/sparkle.js and the matching CSS:

  • Ambient glitter — ambientSparkles($('#sparkles')) in main.js takes 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. CONFETTI at 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 #aurora in the CSS; its colours are the four radial gradients, its pace is aurora-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.

Things designed in on purpose

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

Grown-up settings

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.

Layout

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

The Stay Safe chapter

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 7

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

How the board gets its size

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.

Why the chess rules are hand-written

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.

About

Teaching Drina Chess

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages