GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 CSS Game Demos — Mini Games in HTML & CSS

A css game runs its rules and its screen on nothing but selectors, keyframes, and transform.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order is not by popularity; it follows who holds the verdict. In the first three, the judging sits almost entirely in CSS (01 a selector chain, 02 a flip transition, 03 a rise transition); in the middle three, JavaScript reads coordinates and focus while CSS paints them (04 cursor chase, 05 an animation clock, 06 focus movement); and in the last three, JavaScript counts frames and timestamps on its own (07 falling dodge, 08 a stopped shuttle, 09 elapsed milliseconds). A search for css only games usually lands on something shaped like 01, where one sibling combinator carries the entire sequence, while by 09 the work has turned into millisecond arithmetic. If it's the card flip itself you're after, 9 CSS Card Flip Animations sits next door; for scenes that lean toward the cursor, there is 3D Hover Effect CSS: 9 Pointer-Following Scenes, and for the key caps themselves, Keyboard Shortcuts UI Design: 9 Working Parts.

01Lit-pad tapper

Only the pad that's lit right now can be pressed, and each press hands the light to the next one, which turns the board into a thirty-second pastime in place of a spinner or eye candy on a 404 page.

Lit-pad tapper
Open in a new tab

01 · Lit-pad tapper — Measured area 47.79% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

:checkedcounter-incrementcontent

Order lock built from sibling combinators

// A dark pad (not reachable yet) has pointer-events:none so the click never lands; only the lit pad comes back to auto
#rpP1:not(:checked) ~ .rp__grid .rp__pad:nth-child(1),
#rpP1:checked ~ .rp__grid .rp__pad:nth-child(2),
#rpP2:checked ~ .rp__grid .rp__pad:nth-child(3),
#rpP3:checked ~ .rp__grid .rp__pad:nth-child(4),
#rpP4:checked ~ .rp__grid .rp__pad:nth-child(5),
#rpP5:checked ~ .rp__grid .rp__pad:nth-child(6),
#rpP6:checked ~ .rp__grid .rp__pad:nth-child(7),
#rpP7:checked ~ .rp__grid .rp__pad:nth-child(8),
#rpP8:checked ~ .rp__grid .rp__pad:nth-child(9) {
  pointer-events: auto; cursor: pointer;
  background-color: $subject-cream; border-color: $subject-cream;
  box-shadow: 0 0 0 4px rgba($subject-cream, .16), 0 0 22px rgba($subject-cream, .3);
  .rp__no { color: $subject-ink; }
}

A pad only lights up once the one before it is :checked, so a press out of turn is stopped by structure rather than by a rule, and the whole verdict rests on the single pseudo-class described on MDN's :checked page.

The score is kept by a CSS counter

.rp {
  display: flex; flex-direction: column; gap: $sp-2;
  width: 100%; max-width: 340px; height: 100%;
  counter-reset: rpTaps;
}
// The checkbox is never painted, but the box stays for the counter and for keyboard focus (display:none would break the counter)
.rp__check { position: absolute; width: 1px; height: 1px; margin: -1px; opacity: 0; pointer-events: none; }
.rp__check:checked { counter-increment: rpTaps; }
.rp__count::after { content: counter(rpTaps) " / 9"; }
// Cleared — if #rpP9 is checked, every pad from 1 through 8 has already been passed
.rp__done { position: absolute; inset: 0; z-index: 3; display: none; place-items: center; margin: 0; }
#rpP9:checked ~ .rp__done { display: grid; }

Not one line of JavaScript tallies the score here, and with 36 animations layered on it, this board measures 47.79% cumulative area and 23/23 frames in motion, the densest movement of the nine.

02Memory pair cards

Two revealed cards stay face up when they match and turn back over when they do not, which makes it a time-killer and, with quiz questions printed on the faces, a learning deck.

Memory pair cards
Open in a new tab

02 · Memory pair cards — Measured area 1.367% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotateY(preserve-3dbackface-visibility

Two faces stacked inside one box

.mm__inner {
  position: absolute; inset: 0;
  transform-style: preserve-3d;
  transition: transform $dur-base $easing;
}
.mm__card.is-open .mm__inner,
.mm__card.is-matched .mm__inner { transform: rotateY(180deg); }

.mm__face {
  position: absolute; inset: 0;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  display: grid; place-items: center;
  border-radius: $r-sm;
}

Front and back occupy the same coordinates, with backface-visibility: hidden erasing whichever one has turned away, so the only job left for JavaScript is adding and removing a class.

Matched cards hold, mismatched return after 700ms

if (a.getAttribute('data-face') === btn.getAttribute('data-face')) {
  setTimeout(function () {
    a.classList.add('is-matched'); btn.classList.add('is-matched');
    a.disabled = true; btn.disabled = true;
    left--; pairsEl.textContent = String(left);
    lock = false;
    if (left === 0) { finish(); }
  }, 350);
} else {
  setTimeout(function () {
    a.classList.remove('is-open'); btn.classList.remove('is-open');
    a.setAttribute('aria-pressed', 'false'); btn.setAttribute('aria-pressed', 'false');
    lock = false;
  }, 700);
}

A lock flag swallows the third click while two cards are open, and because the preview flip is a cut rather than a rotation, this board records 1.367% cumulative area, the lowest figure of the nine.

03Whack-a-mole

A mole rises from a random hole in the 3×3 grid and only scores while it's up, which suits an event page that wants a substitute for a prize wheel or a section built to catch a child's eye.

Whack-a-mole
Open in a new tab

03 · Whack-a-mole — Measured area 33.16% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

@keyframestranslateYpointerdown

Rising and sinking is a single transition

// The mole's body is CSS (half a pill) and only the face is a Phosphor Duotone icon
.wg__mole {
  position: absolute; left: 16%; right: 16%; bottom: 0; height: 76%;
  display: grid; place-items: center;
  border-radius: $r-pill $r-pill 0 0;
  background-color: $color; color: $subject-ink;
  transform: translateY(100%);
  transition: transform $dur-quick $easing;
}
.wg__mole svg { width: 66%; height: 66%; }
.wg__hole.is-up .wg__mole { transform: translateY(0); }
@keyframes wgMoleRise {
  0%   { transform: translateY(100%); }
  8%   { transform: translateY(0); }
  20%  { transform: translateY(0); }
  30%  { transform: translateY(100%); }
  100% { transform: translateY(100%); }
}

The real game adds and removes .is-up and lets transition draw it, while the grid preview rebuilds the same movement in @keyframes, and a negative delay on each hole staggers the phases so the 18 animations line up smoothly inside one loop.

No hole twice in a row

function spawn() {
  if (!playing) { return; }
  sink();
  var i = lastHole;
  while (i === lastHole) { i = Math.floor(Math.random() * holes.length); }   // never spawn in the same hole twice in a row
  lastHole = i; upIndex = i;
  holes[i].classList.add('is-up');
  downId = setTimeout(sink, UP_MS);
  spawnId = setTimeout(spawn, 700 + Math.random() * 300);
}

A mole drops back on its own after 850ms, and the next one arrives 700 to 1000ms later so two are never standing at the same time, which lands the board at 33.16% cumulative area with an average intensity of 129.4, the third loudest of the set.

04Gaze dodge

The eye on screen keeps chasing the cursor and stacks a penalty whenever it settles on you, which makes it a decent way to teach pointer events or to fill a cursor-interaction slot in a portfolio piece.

Gaze dodge
Open in a new tab

04 · Gaze dodge — Measured area 27.768% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermovegetBoundingClientRecttranslate3d

Coordinates zeroed at the playfield corner

function arenaRect() { return arena.getBoundingClientRect(); }
function placeTarget(x, y) {
  var r = arenaRect();
  targetPos.x = Math.max(8, Math.min(r.width - 8, x));
  targetPos.y = Math.max(8, Math.min(r.height - 8, y));
}
function centerOf(el) {
  var r = el.getBoundingClientRect();
  var a = arenaRect();
  return { x: r.left - a.left + r.width / 2, y: r.top - a.top + r.height / 2 };
}
function paint() {
  eyeWrap.style.transform = 'translate3d(' + (eyePos.x - eyeWrap.offsetWidth / 2) + 'px,' + (eyePos.y - eyeWrap.offsetHeight / 2) + 'px,0)';
  targetWrap.style.transform = 'translate3d(' + (targetPos.x - targetWrap.offsetWidth / 2) + 'px,' + (targetPos.y - targetWrap.offsetHeight / 2) + 'px,0)';
}

getBoundingClientRect shifts window coordinates onto the playfield once, and from then on two translate3d lines do all the placement, so left and top are never touched again.

An eye that closes 5.5% of the gap every frame

function tick(now) {
  var dt = now - lastNow;
  lastNow = now;
  var demo = root.classList.contains('is-demo');
  if (!demo) {
    // lerp chase — the eye closes part of the gap toward the target on every frame
    eyePos.x += (targetPos.x - eyePos.x) * LERP;
    eyePos.y += (targetPos.y - eyePos.y) * LERP;
    paint();
    var ex = eyePos.x, ey = eyePos.y;
    var near = Math.hypot(targetPos.x - ex, targetPos.y - ey) < DANGER_DIST;
    root.classList.toggle('is-near', near);
    if (running) {
      var sec = (now - startT) / 1000;
      scoreNum.textContent = fmtTime(sec);
      if (near) {
        dangerAcc += dt;
        if (dangerAcc >= DANGER_MS) { dangerAcc = 0; addPenalty(); }
      } else {
        dangerAcc = Math.max(0, dangerAcc - dt * 2);
      }
    }
  }
  window.requestAnimationFrame(tick);
}

Taking only LERP of the remaining distance means the eye never quite arrives yet never stops closing in, and a penalty costs 450ms spent inside 60px while the gauge drains at twice that speed once you're clear.

05Rhythm tap

Notes drop down three lanes, and J, K, and L are struck as each one crosses the judgment line — enough for keyboard practice or finger play on a waiting screen.

Rhythm tap
Open in a new tab

05 · Rhythm tap — Measured area 22.402% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

animation-play-stategetAnimationstranslateY

The judging clock belongs to the animation

// Wall-clock value: time elapsed since the song started, converted into this note's own fall time
function wallNow(note) {
  return (performance.now() - st.t0) - Number(note.getAttribute('data-due')) + st.fallMs;
}
// The clock used for judging — a note is judged where it is actually drawn, so read the fall
// animation's own clock (currentTime minus delay = time elapsed since the fall began).
function noteNow(note) {
  if (REDUCE) { return wallNow(note); }
  var a = fallAnim(note);
  if (a && a.effect && a.playState !== 'finished') {
    var ct = a.effect.getComputedTiming();
    var delay = a.effect.getTiming().delay || 0;
    if (ct.localTime != null) { return ct.localTime - delay; }
  }
  return wallNow(note);
}

Grading against a wall clock drifts away from the drawn position on a browser that's running late, so the localTime of the falling animation, picked up through getAnimations, becomes the reference: the spot a note was painted is the spot that scores.

A hit note freezes where it was struck

.rtl__note {
  animation-name: rtlFall; animation-duration: $duration; animation-timing-function: linear;
  animation-iteration-count: 1; animation-fill-mode: both;
}
// Hit — freeze the fall in place (the position at the moment of judging) while the core pops from its own center
.rtl__note.is-hit { animation-play-state: paused; }
.rtl__note.is-hit::before {
  animation-name: rtlPop; animation-duration: 240ms; animation-timing-function: $easing;
  animation-iteration-count: 1; animation-fill-mode: forwards;
}
@keyframes rtlFall {
  from { transform: translateY(-16px); }
  to   { transform: translateY(var(--fall-h)); }
}

As MDN's animation-play-state page puts it, paused freezes progress rather than discarding it, so the note sticks at the height where it was hit, while the window splits at 115ms for a perfect and 240ms for a good.

06Focus maze escape

Arrow keys walk a cell toward the exit, and walls stop you because they never accept focus in the first place, which makes it a keyboard-accessibility demo or an onboarding piece that teaches focus traversal.

Focus maze escape
Open in a new tab

06 · Focus maze escape — Measured area 7.31% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

keydown:focus-visiblegrid-template-areas

The maze is five lines of text

// 7×5 maze — . is a path, # is a wall, S is the start, G is the exit.
// Same layout as the grid-template-areas strings (the cell names in style.scss below follow this table).
var MAZE = [
  'S.#...G',
  '.#..#.#',
  '...#..#',
  '#.##..#',
  '......#'
];
var COLS = 7, ROWS = 5;
function at(c, r) { return (MAZE[r] || '')[c] || '#'; }
function isOpen(c, r) { return c >= 0 && c < COLS && r >= 0 && r < ROWS && at(c, r) !== '#'; }

Paths are button elements and walls are plain divs, so the rule that walls block never has to be written anywhere, and reshaping the maze means editing these five strings and nothing else.

The character is drawn by the focus selector

.fme__maze {
  display: grid; gap: $sp-1; justify-content: center;
  grid-template-columns: repeat(7, clamp(22px, 6.4vw, 32px));
  grid-template-areas:
    "c0r0 c1r0 c2r0 c3r0 c4r0 c5r0 c6r0"
    "c0r1 c1r1 c2r1 c3r1 c4r1 c5r1 c6r1"
    "c0r2 c1r2 c2r2 c3r2 c4r2 c5r2 c6r2"
    "c0r3 c1r3 c2r3 c3r3 c4r3 c5r3 c6r3"
    "c0r4 c1r4 c2r4 c3r4 c4r4 c5r4 c6r4";
  border-radius: $r-card; padding: $sp-1; background: rgba($subject-ink, .12);
  justify-self: center;
}
// Character — visible only in the cell that holds focus. Walls are not buttons, so they take no focus and cannot be entered
.fme__char { width: 68%; height: 68%; opacity: 0; transform: scale(.6); color: $color; }
.fme__cell:focus .fme__char {
  opacity: 1; transform: scale(1);
  transition: opacity $duration $easing, transform $duration $easing;
}
.fme__cell:focus-visible { outline: 2px solid $subject-ink; outline-offset: -2px; }

A character hidden inside every cell shows up only in the focused one, so the single thing JavaScript moves is focus, and keeping :focus-visible separate means the outline appears only when the keyboard put it there.

07Falling dodge

Steering the rocket along the floor past falling obstacles ends after three hits, which fits a play corner on an event landing page or a UI that sells reaction speed.

Falling dodge
Open in a new tab

07 · Falling dodge — Measured area 14.249% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

keydowntranslateXrequestAnimationFrame

Movement per frame, falling left to CSS

function tick(now) {
  if (!running) { return; }
  var dt = Math.min(64, now - lastT) / 1000;
  lastT = now;
  var dir = (keys.right ? 1 : 0) - (keys.left ? 1 : 0);
  if (dir !== 0) {
    pointerX = null;
    px += dir * PLAYER_SPEED * dt;
  } else if (pointerX !== null) {
    var d = pointerX - px;
    var step = PLAYER_SPEED * dt;
    px += Math.abs(d) <= step ? d : Math.sign(d) * step;
  }
  placePlayer();
  if (now >= invulnUntil) {
    var pr = player.getBoundingClientRect();
    var hit = { left: pr.left + 5, right: pr.right - 5, top: pr.top + 6, bottom: pr.bottom - 4 };
    for (var i = rocks.length - 1; i >= 0; i--) {
      if (rectsOverlap(hit, rocks[i].getBoundingClientRect())) { hitRock(rocks[i]); break; }
    }
  }
  rafId = requestAnimationFrame(tick);
}

Only the rocket travels under requestAnimationFrame while @keyframes dfFall carries the obstacles, so even with ten rocks in the air JavaScript has one coordinate to track, and clamping any frame longer than 64ms keeps the rocket from teleporting when the tab wakes back up.

A hitbox smaller than the visible box

function rectsOverlap(a, b) {
  return a.left < b.right - 2 && a.right > b.left + 2 && a.top < b.bottom - 4 && a.bottom > b.top + 4;
}
function hitRock(rock) {
  rock.remove();
  var i = rocks.indexOf(rock);
  if (i >= 0) { rocks.splice(i, 1); }
  lives -= 1;
  paintLives();
  invulnUntil = performance.now() + 900;
  player.classList.remove('is-hit');
  void player.offsetWidth; // reflow trick that replays the hit-flash animation from the start
  player.classList.add('is-hit');
  if (lives <= 0) { gameOver(); }
}

Pulling the four sides 2 to 6px inward is what removes the complaint that a near miss counted as a hit, and the 900ms of invulnerability afterwards stops one rock from taking a second life.

08Stack tower timing

A block sliding back and forth is stopped to stack it, and whatever hangs over the edge gets trimmed away, which is the shape of a timing-led promotion or a competitive scoreboard on a waiting screen.

Stack tower timing
Open in a new tab

08 · Stack tower timing — Measured area 5.453% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramescaleXtransition

Shrinking a width without changing the width

.st__tower { position: absolute; left: 50%; bottom: 12px; will-change: transform; }
// Floors, the moving block and the trimmed scrap share one spec: left -64px (centres the 128px base block on the tower) +
// translateX(--lx) + scaleX(--sx). lx is an offset with the base block's left edge at 0 (see BASE_W and HALF in the JS).
.st__row, .st__mover, .st__chip {
  position: absolute; left: -64px; bottom: var(--by, 0px);
  width: 128px; height: 18px;
  border-radius: $r-xs;
  transform: translateX(var(--lx, 0px)) scaleX(var(--sx, 1));
  transform-origin: left center; // the width shrinks from the left edge — the reference edge for stack alignment
  will-change: transform;
}

Trimming with width forces layout to be recomputed on every floor while scaleX changes only what's painted, which is how the board carrying the fewest animations of the nine — two of them — still reports 13/23 frames in motion.

Three values keep only the overlap

function setGeom(el, lx, w, by) {
  el.style.setProperty('--lx', lx + 'px');
  el.style.setProperty('--sx', String(w / BASE_W));
  el.style.setProperty('--by', by + 'px');
}
function paintMover() { setGeom(moverEl, mover.lx, mover.w, rows.length * STRIDE); }
function addRowEl(row) {
  var el = document.createElement('div');
  el.className = 'st__row is-new';
  setGeom(el, row.lx, row.w, (rows.length - 1) * STRIDE);
  el.addEventListener('animationend', function () { el.classList.remove('is-new'); });
  tower.appendChild(el);
}

Horizontal offset, horizontal scale, and floor height travel as three custom properties so a fresh floor and a trimmed scrap draw by the same rule, and landing within 4px on both edges earns a bonus that restores the full 128px base width.

09Reaction time test

Pressing the instant the signal lights measures how long that took, which is team-page fun or the raw material for a reaction-speed benchmark.

Reaction time test
Open in a new tab

09 · Reaction time test — Measured area 48.163% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

performance.nowtransitionclassList

The state machine between waiting and the signal

function arm() {
  root.classList.remove('is-demo'); // drop the grid preview staging and switch to the real game
  waitTitleEl.textContent = ARM_TITLE;
  waitSubEl.textContent = ARM_SUB;
  setState('armed');
  clearTimeout(armTimer);
  armTimer = setTimeout(fire, WAIT_MIN + Math.random() * WAIT_RANGE);
}
function fire() {
  signalAt = performance.now();
  setState('signal');
}
function act() {
  if (state === 'armed') { penalize(); return; }
  if (state === 'signal') { settle(); return; }
  arm(); // from idle, result or penalty, start the next round
}

The wait is a random 1.5 to 4 seconds so nobody can memorize it, and the same input becomes a penalty, a measurement, or the start of the next round depending on the state, which removes any need for separate buttons.

Four faces stacked on one spot

.rt__panel {
  position: relative; overflow: hidden;
  height: clamp(104px, 52vh, 180px);
  border: 2px solid rgba($subject-cream, .16);
  border-radius: $r-card;
  background: $tick-panel;
  // Background swap per state — two high-contrast colors joined by one transition (the signal is the only effect)
  transition: background-color $dur-quick $easing;
  touch-action: manipulation;
  cursor: pointer;
}
.rt.is-signal .rt__panel { background: $color; }
.rt.is-penalty .rt__panel { background: $error-soft; }
.rt.is-idle .rt__face--wait,
.rt.is-armed .rt__face--wait,
.rt.is-signal .rt__face--go,
.rt.is-result .rt__face--result,
.rt.is-penalty .rt__face--penalty { opacity: 1; }

The whole panel changes color in one go, which gives it the largest peak area of the nine at 48.045%, and because the only moving instant is the signal itself, its motion count is the lowest at 11/23 frames.

Where can you run css game code like this unchanged?

The nine share exactly three values and keep everything else local to the item, which draws a sharp line between what you may edit and what has to be left alone.

Values that carry all nine at once

name changed value feel
duration $dur-loop 2s → 1s falling notes and blinking moles run twice as fast, so judging gets harder
easing $ease-out → $ease-pop moles and pads bounce one extra time, which pins down the moment of a hit
color $subject-blue → $stage-orange the calm playfield turns festive and drops easily onto an event page

Values that only work as a pair

Most css mini games break at a seam like this rather than in the animation: the maze table in 06 lives in the same coordinate system as the cell names in grid-template-areas, so stretching one side alone puts walls in the wrong places, and both have to grow together. In 08 the trimmed width is only correct while BASE_W in the JavaScript and width: 128px on .st__row hold the same number, and in 05 a beat is 480ms with judgment windows of 115, 240, and 400ms stacked on top of it, so shrinking the beat means shrinking the windows too.

Where it breaks — pitfalls

Once all nine were finished, the places that had broken were not the animations but the input and the timers.

A held key swallows an entire round

Hold a key down and the browser repeats keydown on its own. In 05 the next note in the same lane was judged along with the first and the combo climbed by itself; in 09 the repeat arrived while the round was still armed, and the too-soon penalty printed over and over. Both handlers now open with if (e.repeat) { return; } so only the first press a human actually made gets through. The password for the nine zip files is cj2ffvjv, and opening them shows that one line sitting at the top of the keydown handler in 05 and 09 exactly as written here.

A wait timer left running fires after the penalty

Pressing before the signal in 09 is a penalty, but if the arming timer set earlier is still alive, the signal lights up while the penalty screen is still showing. That's why both penalize() and resetIdle() begin with clearTimeout(armTimer).

One function named top kills the whole script

Naming the function that returns the top floor in 08 top stopped the board from drawing at all. window.top is a global name the browser reserves, so a function declaration sharing it is ignored outright and every call after that collapses. Renaming it topRow was the entire fix.

Preview code and real code drift apart

What runs in the grid thumbnail is @keyframes that only play while .is-demo is attached, while movement after a human touches it is mostly transition. The preview flip in 02 was cut with steps(1, end) to keep a mid-rotation edge-on view out of the poster frame, and the price of that cut is the 1.367% cumulative area on that board. One path can be dead while the measurement still passes, so all nine were pressed by hand with .is-demo removed.

Accessibility

The standard for this set is that reduced motion never takes the game itself away.

What reduced-motion turns off and what it keeps

WCAG 2.2.2 Pause, Stop, Hide asks for self-starting motion to stop, not for function to be removed. So under prefers-reduced-motion: reduce, all nine drop the grid preview loop and their transitions while the judging keeps running. 04 raises its interpolation factor to 1 so the eye arrives instantly; 05 lights notes in place at the judgment line; the fall in 07 becomes a stepped steps(8, end) move; and 08 picks a landing spot inside the safe range on each input instead of shuttling.

Where the keyboard and assistive tech get in

Every board has a restart control, 01 moves focus onto the lit pad so the space bar alone can finish a run, and 06 moves focus between path cells with the arrow keys. The playfields in 07, 08, and 09 carry role="application" with a describing label so a screen reader hands the arrow keys to the game, the maze in 06 is a role="group", the result lines in 01, 02, and 03 are role="status", and the verdict text in 05 announces through aria-live="polite".

FAQ

Which one to open first if you want css games to learn from

01 is the shallowest. Read a sibling combinator and counter-increment and you understand it up to the scoreboard, since JavaScript only handles the restart and the focus move — which puts selector study and game building in one file. 05, 08, and 09 carry millisecond arithmetic and are better saved for a second sitting.

Can a game whose logic is pure CSS ship in a real product

Yes, when the score never has to be rolled back and the state only travels one way. 01 is that case: the checkbox state is the progress, a refresh resets it, and nothing needs to reach a server. The moment a leaderboard or anti-cheating enters the picture, JavaScript has to hold the state instead.

Do all nine work on mobile touch

The tapping boards — 01, 02, 03, 07, 08, and 09 — work as they are on touch. 04 needs a pointer to be dragged, so arrow-key control was added alongside it, while 05 and 06 are built on key input and feel right with a physical keyboard. The panel in 09 sets touch-action: manipulation so repeated taps never turn into a zoom.

Enter the archive password

The password is inside this article. You will find it as you read.