Design Lounge
Install

Nº 632 of 677

Stack drop mini-game

A one-tap stone stacker on a dusk ridge: the overhang slices off and falls, perfect drops chain a glow, speed rises and the sky turns to night.

Preview

Open full size
390 × 844 · 15.8 KB · 220 lines

Use it

Free to use · credit appreciated

1 Once, in your project · Node 22.20+

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Stack drop mini-game" (piece minigame-stack-drop) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

220 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

Cairn is a one-tap timing game: a stone slab slides side to side above a cairn on a mountain ridge, and a tap (or Space) sets it down. Whatever hangs past the stone below is sliced off and tumbles away with gravity and spin; a drop within 4px of perfect keeps the full width and starts a glowing combo. The slide speeds up with every stone, the camera climbs with the cairn, and the sky slowly goes from dusk peach to night blue over 30 stones while stars brighten. It is drawn flat in 2D on a canvas, art-directed rather than neon: eight earthy stone tones with a lit top lip and a shadow lip, three parallax ridges, a low moon, and a quiet HUD in Bricolage Grotesque with Geist Mono labels. The detail worth copying is how calm the frame stays while the game speeds up: one big score, one hint pill, dialogs only when paused or over. Dialogs follow iOS 26-ish sheet proportions (28px radius, sheet curve).

Reference behaviour

  1. Ready (first frame): the board is visible with three foundation stones (220, 198 and 176px wide) on a dark rock plinth, and the first stone sliding slowly (110 px/s) with a 2px bob. A pill at the bottom says “Tap to set the stone” with a pulsing ember dot and a SPACE key cap. Score 0, BEST 0.
  2. The first tap, Space, Enter or ↓ drops the stone and starts play; the hint fades out (300ms).
  3. Each new stone enters from alternating sides at the canvas edge (x = 8 or 382 − width) and bounces between those edges. Speed is min(460, 150 + 10 × score) px/s.
  4. Drop: compare the stone’s left edge with the top stone’s.
    • |dx| ≤ 4px: perfect. The stone snaps into line and keeps its width; the combo goes up; a warm #FFD9A0 outline expands from the stone and fades over 700ms, thicker and wider for longer combos. From ×2 on, a bone pill “Perfect ×N” pops at the top of the screen (900ms).
    • Partial overlap: the stone is trimmed to the overlap; the cut piece becomes a falling chip (gravity 1,900 px/s², spin ±2.2 rad/s away from the cairn). The combo resets.
    • No overlap: the whole stone falls (spin ±1.2 rad/s) and the game is over 650ms later.
  5. After each placed stone the score bumps (scale 1 → 1.12 → 1, 240ms) and the camera target rises so the top of the cairn sits about 380px above the ground line; the camera eases toward it at 6·dt per frame.
  6. Pause: the round button (top right), P or Escape. The loop stops (cancelAnimationFrame), a scrim (rgba(10,13,24,.72)) and a bone sheet “Paused · height 7 / The wind can wait.” appear with Resume and “Start a new cairn”. P or Escape resumes. Leaving the tab pauses automatically.
  7. Game over sheet: “Game over · dusk on the ridge”, “The cairn held 12 stones.”, Score and Best tiles (40px figures), a “NEW BEST” tag when beaten, “Stack again”, and “Longest perfect run N · Enter to restart”. Enter or Space restarts. The loop stops 1.2s after the sheet appears.
  8. Best lives in memory only and survives restarts, not reloads.

Structure

390 × 844 · canvas fills the viewport; HUD and sheets are DOM on top
┌──────────────────────────────────┐
│ 54px clear                       │
│ CAIRN          12         ( ‖ )  │ HUD grid 96px | 1fr | 96px, top 58px
│               BEST 18            │ score 76px, BEST 12px mono
│                                  │
│     · stars ·            ( moon )│ moon r22 at (310, ground − 300)
│                                  │
│  [==stone==] →                   │ moving stone, 28px row (24 visible + 4 lip)
│      [===stone===]               │
│     [====stone====]   ╱╲ ridges  │ three ridges, parallax .08 / .2 / .45
│    [=====stone=====]             │
│   [======stone======]            │ foundation 220 / 198 / 176
│  ┌──────── plinth 270 ───────┐   │ ground line = viewport height − 196
│  │ [• Tap to set the stone SPACE]│ hint pill, bottom 58px
│  └───────────────────────────┘   │
│ 34px home indicator              │
└──────────────────────────────────┘
Sheets: bottom-aligned, width min(358px, 100%), 16px gutters, 34px bottom inset.
  • canvas role="img" aria-label="Cairn stacking game board", sized to innerWidth × innerHeight at up to 2× DPR. The 390px play column is centred with ox = (W − 390) / 2, so on wide screens the sky and ridges fill the rest.
  • header.hud: wordmark, score (aria-live="polite"), best, pause <button aria-label="Pause">.
  • Combo pill: div role="status".
  • Two div.layer role="dialog" aria-modal="true" each holding a .panel.

Tokens

:root {
  /* sky and ground */
  --night: #141A2C;        /* sky top, plates, page */
  --dusk: #3B3557;         /* sky mid at dusk */
  --sky-mid-night: #1E2440;
  --horizon-dusk: #D9826A; --horizon-night: #33406A;
  --low-dusk: #F0B183;     --low-night: #3E4C78;
  --ridge-far: #6B5874;    --ridge-far-night: #3A3E5E;
  --ridge-mid: #4A3E57;    --ridge-mid-night: #2A2D48;
  --ridge-near: #1F1B2B;
  --plinth: #17141F; --plinth-line: #2A2536;
  --moon-dusk: #F6D2A8; --moon-night: #E9E4D6;
  /* stones, in order */
  --stone-1: #D9C3A5; --stone-2: #A7756A; --stone-3: #8E9A8A; --stone-4: #C7A15A;
  --stone-5: #9FB1B8; --stone-6: #B98B6E; --stone-7: #E2B08C; --stone-8: #7F8C6E;
  /* top lip = stone mixed 28% toward white; under lip = stone darkened 35% */
  --perfect-glow: #FFD9A0;
  /* UI */
  --bone: #F3EADB;          /* score, sheets */
  --ink: #1B1A24;           /* sheet text */
  --sub: #5A5560;           /* sheet labels (6.1:1 on bone) */
  --on-plate-2: #B9B6C8;    /* wordmark, BEST (8.7:1 on night) */
  --edge: #4A4866;          /* 1.5px inset ring on plates */
  --ember: #E8875A;         /* the one accent: hint dot, primary button */
  --ember-ink: #2A160C;     /* primary label (6.6:1 on ember) */
  --new-best: #9A3E14;
  --scrim: rgba(10, 13, 24, .72);
  /* type */
  --display: "Bricolage Grotesque", system-ui, sans-serif;
  --mono: "Geist Mono", ui-monospace, monospace;
  --fs-score: 76px; --fs-stat: 40px; --fs-title: 34px; --fs-primary: 19px;
  --fs-ui: 15px; --fs-label: 12px;
  /* game metrics */
  --row: 28px; --start-w: 176px; --perfect: 4px; --column: 390px;
  --speed-ready: 110; --speed-base: 150; --speed-step: 10; --speed-max: 460; /* px/s */
  --gravity: 1900; /* px/s² */
  /* radii and sizes */
  --r-stone: 6px; --r-sheet: 28px; --r-primary: 18px; --r-ghost: 16px; --r-tile: 16px; --r-pill: 999px;
  --hit: 44px; --primary-h: 58px; --ghost-h: 50px;
  /* motion */
  --ease: cubic-bezier(.2, .7, .2, 1);
  --pop: cubic-bezier(.34, 1.56, .64, 1);
  --sheet-ease: cubic-bezier(.32, .72, 0, 1);
  --t-bump: 240ms; --t-glow: 700ms; --t-combo: 900ms; --t-sheet: 400ms; --t-scrim: 250ms;
}

Typography

RoleFamilySizeWeightLine-heightTrackingNotes
ScoreBricolage Grotesque, opsz 9676px800.9−0.03emtabular
Sheet statBricolage Grotesque40px8001−0.02emtabular
Sheet titleBricolage Grotesque34px8001−0.02emsentence
Primary buttonBricolage Grotesque19px70010sentence
Ghost buttonBricolage Grotesque16px60010sentence
Hint, combo pillBricolage Grotesque15px600 / 70010sentence
Wordmark, BEST, labelsGeist Mono12px500–60010.14–0.18emupper
Sheet footnoteGeist Mono12px5001.30sentence

Motion

ThingTriggerPropertyFrom → toDurationEasingReduced motion
Ready bobreadystone y±2px sine4 rad/s—kept (tiny)
Hint dot pingreadyring scale, opacity1, .9 → 1.9, 01,400ms loop--easestatic dot
Hint outfirst dropopacity1 → 0300ms--easesame
Slideplayxedge ↔ edgespeed 150 + 10·score px/slinear (physics)same: the game stays playable
Chip falltrim / missy, rotationgravity 1,900 px/s², ±2.2 rad/suntil off-screenphysicssame
Perfect glowperfectoutline spread, alpha3px, 1 → 13+3k px, 0700mslinearsame (no movement of layout)
Combo pillperfect ×2+opacity, y, scale0, 10px, .8 → 1 → 0, −14px900ms--easeopacity only
Score bumpplacedscale1 → 1.12 → 1240ms--popnone
Cameraplacedworld offset→ targetk = 6·dt per frameexponentialk = 30·dt (near snap)
Skyscore 0 → 30gradient stopsdusk → nightcontinuouslinear in scoresame
Sheetpause / overtranslateY24px → 0400ms--sheet-easenone, crossfade only
Scrimpause / overopacity0 → 1250ms--easesame

States

  • Ready: slow slide, hint visible, nothing scored. Any drop input starts play.
  • Play: loop running; HUD only.
  • Perfect / combo: glow on the placed stone; pill from ×2.
  • Paused: loop stopped; sheet with Resume (focused) and Start a new cairn. The pause button stays visible but sits under the scrim.
  • Over: whole stone falls, sheet after 650ms with Stack again focused; NEW BEST tag only when the best was beaten with more than 0.
  • Focus-visible: 3px ember outline offset 3px on every button.
  • Primary active: scale .98.

Accessibility

  • Keys: Space, Enter or ↓ drop; P or Escape pause and resume; Enter or Space restart from the game-over sheet. Drop keys are ignored while focus is inside a sheet or on the pause button, so they never double-fire a button.
  • After Resume or restart, focus is released to the page so Space goes back to dropping.
  • Score is aria-live="polite"; the combo pill is role="status"; sheets are role="dialog" aria-modal="true" with aria-labelledby on their titles; closed sheets are visibility:hidden.
  • Text never relies on the canvas behind it: the HUD sits over the always-dark top of the sky (#141A2C to the 42% stop), and the hint and combo sit on solid pills.
  • Contrast: bone on night 14:1, #B9B6C8 on night 8.7:1, ink on bone 14:1, ember-ink on ember 6.6:1, white on new-best 6.8:1.
  • Targets: pause 44px, primary 58px, ghost 50px; the whole canvas is the tap target for drops (pointerdown, touch-action:manipulation).

Responsive rules

  • The canvas always fills the viewport; game logic stays in a 390px column centred with ox. On a 360px screen use scale = W / 390 on the column instead of clipping it.
  • The ground line is viewport height − 196; the camera keeps the top stone about 380px above it, so short screens still show the HUD and the cairn.
  • On resize, recompute size and DPR, then redraw immediately when the loop is stopped.
  • Tablet: sky and ridges stretch; sheets stay min(358px, 100%) wide at the bottom.

Acceptance checklist

Always

  • First frame is the ready state with the board, a moving stone and a one-line hint; no title screen.
  • A drop within 4px keeps the full width; any other overlap trims to the overlap and the cut piece falls with gravity and spin.
  • No overlap ends the game, with a score, the best and a restart.
  • Speed rises with each stone and is capped.
  • The loop uses requestAnimationFrame with delta time capped at 1/30s, and stops when paused or over.
  • Touch and keyboard both work: tap, Space, Enter, ↓, P, Escape.
  • Closed sheets are visibility:hidden; focus moves into an opened sheet.
  • Best is held in memory only; no localStorage.
  • Text over the canvas sits on dark sky or a solid pill and passes 4.5:1.
  • document.documentElement.scrollWidth is 390.

This demo

  • Wordmark “CAIRN”; hint “Tap to set the stone” with a SPACE key cap.
  • Pause sheet “Paused · height N”, “The wind can wait.”, Resume, Start a new cairn.
  • Game-over sheet “The cairn held N stones.”, Score / Best tiles, NEW BEST, Stack again, “Longest perfect run N”.
  • Sky turns from dusk to night over 30 stones; eight stone tones cycle.

Implementation notes

  1. The drop is one comparison. Work in column coordinates (0–390), not canvas pixels, and keep the trimmed piece on the side it overhung.
const top = stack.at(-1), m = mover, dx = m.x - top.x;
if (Math.abs(dx) <= 4) {
  stack.push({ x: top.x, w: top.w, c: m.c }); combo++;
} else {
  const l = Math.max(m.x, top.x), r = Math.min(m.x + m.w, top.x + top.w);
  if (r - l <= 0) { chips.push({ ...m, y: stack.length, vy: 0, rot: 0, vr: m.dir * 1.2 }); return gameOver(); }
  stack.push({ x: l, w: r - l, c: m.c }); combo = 0;
  chips.push({ x: dx > 0 ? r : m.x, w: m.w - (r - l), y: stack.length - 1, vy: 0, rot: 0,
               vr: (dx > 0 ? 1 : -1) * 2.2, c: m.c });
}
  1. A loop that can stop. Cap the delta so a backgrounded tab does not teleport the stone, and cancel the frame on pause and game over instead of skipping work inside it.
function loop(now) {
  const dt = Math.min(1 / 30, (now - last) / 1000); last = now;
  if (state === 'play' || state === 'ready') {
    mover.x += mover.dir * (state === 'ready' ? 110 : Math.min(460, 150 + score() * 10)) * dt;
    if (mover.x < 8) { mover.x = 8; mover.dir = 1; }
    if (mover.x + mover.w > 382) { mover.x = 382 - mover.w; mover.dir = -1; }
  }
  for (const c of chips) { c.vy += 1900 * dt; c.y -= c.vy * dt / 28; c.rot += c.vr * dt; }
  cam += (camTarget - cam) * Math.min(1, dt * 6);
  draw(); raf = requestAnimationFrame(loop);
}
function start() { if (!raf) { last = performance.now(); raf = requestAnimationFrame(loop); } }
function stop() { cancelAnimationFrame(raf); raf = 0; }
  1. Stones read as stone because of two lips, not a gradient: draw the stone darkened 35% shifted 3px down, the stone colour on top (height 24), then a 5px highlight strip 2px from the top in the colour mixed 28% toward white, all with roundRect radius 6 (3 for the strip). A common mistake is putting the score over a part of the sky that changes colour; keep the top 42% of the gradient dark at every stage of the dusk-to-night blend.

Details

Palette
Type
Bricolage Grotesque · Geist Mono
Motion
Rich motion
Build
Multi-step
Designed bySusan AcharyaKathmandu

Studied

Moremedia

A control. It studies this site for quiet, not for a show.

All sources

Install once. Ask for anything.

The MCP server gives your agent 600+ pieces, the themes and the review tools. It installs the skill too. Free, no account.

claude mcp add -s user design-lounge -- npx -y design-lounge-mcp@latest

Claude Code. For Cursor, Codex, VS Code, ChatGPT and the skill alone, see Install.