A self-contained, local-first falling-block event game for LangChain Interrupt.
Play a baseline run, pick an intervention, then run again. The leaderboard ranks by improvement, not raw skill.
- Keyboard controls with custom DAS/ARR repeat
- USB gamepad support
- Two-run improvement loop
- Five interventions: Planner, Memory, Evaluator, Runtime, Human-in-the-loop
- Persistent leaderboard in the browser's localStorage
- Fullscreen big-screen layout
- Fully offline after the files are on the machine: no database, account, cloud service, or API
Requires Node 20+ and pnpm.
pnpm install
pnpm dev # development server with hot reloadFor event use, build once and serve the production bundle:
pnpm build
pnpm serve # http://localhost:8080Or just double-click start-mac.command, which does install, build, and serve in one step.
pnpm serve uses --strictPort, so it fails loudly if something else already holds 8080
rather than quietly moving to another port.
- Left / Right arrows: move
- Down arrow: soft drop
- Up arrow or X: rotate clockwise
- Z: rotate counter-clockwise
- Space: hard drop
- C or Shift: hold / switch
- P: pause
- U: human-in-the-loop undo (only during that intervention)
- 1-5: pick an intervention on the results screen
- Enter: start from the attract screen
pnpm check # the gate: biome + tsc + tests at 100% coverage
pnpm test # tests only
pnpm test:coverage
pnpm lint:fix # apply biome formatting and safe fixes
pnpm brand:lint # verify index.html and the stylesheets against the LangChain brand specpnpm check is the single command that must pass before committing. Coverage thresholds are
set to 100% for lines, branches, functions, and statements, enforced per file.
Functional core, imperative shell. The game rules are pure and the browser is held at arm's length behind ports, which is what makes 100% coverage reachable without a real canvas.
src/domain/ pure game rules: board, pieces, bag, scoring, risk, interventions, reducer
src/ports/ interfaces for clock, rng, storage, gamepad, dialog, fullscreen, 2D renderer
src/render/ pure drawing against the Renderer2D port (board, previews, intervention HUDs)
src/ui/ DOM lookup, screen markup, state-to-text presenter, list rendering, fx specs
src/input/ keyboard (DAS/ARR) and gamepad edge detection, both pure over an injected clock
src/app/ bootstrap wiring plus the real browser ports
src/main.ts composition root
tests/ specs mirroring the src tree one-for-one
tests/support/ shared fixtures: the Renderer2D spy, seeded rng, state builders, DOM mounting
src/ holds only shipped code and tests/ only specs, so coverage.include: ['src'] puts
every product module in scope structurally rather than by pattern. The two type-only modules
(domain/types.ts, ports/index.ts) are the only exclusions; they erase to nothing at
runtime, so v8 reports 0/0 and they can never satisfy a per-file threshold.
reduce(state, action, ctx) in src/domain/reducer.ts is the whole state machine. Traces and
moment banners are state, not side effects, so the shell only ever renders what it is given.
The app occupies exactly one viewport and nothing at page level scrolls. Each canvas simply
fills its container; syncSurfaces() in src/app/browser-ports.ts runs every frame and sizes
each backing store to the laid-out box times devicePixelRatio, then fits the logical surface
inside it with a single uniform scale and centres the remainder.
Two consequences worth knowing before editing the stage:
- Renderers draw in fixed logical coordinates (
BOARD_SURFACEetc. insrc/render/palette.ts) and never readcanvas.width. Resolution is entirely the adapter's problem, so the board is crisp on a HiDPI panel and scales up to a large booth screen without touching draw code. - Do not give a canvas a CSS
aspect-ratioor a percentage height. The adapter sizes the backing store from the laid-out box, so those properties end up defining each other and the element silently overflows its container. The uniform fit already preserves square cells; the letterbox margin is never drawn into and shows the stage background, which is the same colour.
The stylesheets in src/styles/ follow the LangChain brand system. Constraints worth
knowing before editing:
- Dark ground
#030710, one accent#7fc8ff. Never pure#000or#fff. - Headings are Inter 300. Labels, numbers, and CTAs are IBM Plex Mono,
letter-spacing: 0. - Border radius maxes out at 14px; only pills use 100px.
- No type effects and no gradients on backgrounds. Gradients belong on lines and nodes.
- Multi-word mono text stays title case.
- Colours used for canvas drawing live in
src/render/palette.tsand are checked againsttests/support/brand-allowlist.ts, so an unvetted hex fails the suite.
pnpm brand:lint runs an additional linter that is part of an internal LangChain brand
skill. That skill is not in this repository, so the script prints a notice and exits
successfully when it is absent; pnpm check does not depend on it.
Fonts are bundled in public/fonts/ so the game never depends on a CDN. Both are used
under the SIL Open Font License 1.1 — see public/fonts/README.md.
Pushing to main builds the app and publishes dist/ to GitHub Pages via
.github/workflows/pages.yml. The Vite base is ./,
so the bundle works from a repository subpath without further configuration.