Skip to content

Repository files navigation

Interrupt Agent Arena

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.

What works

  • 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

Run it

Requires Node 20+ and pnpm.

pnpm install
pnpm dev          # development server with hot reload

For event use, build once and serve the production bundle:

pnpm build
pnpm serve        # http://localhost:8080

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

Keyboard controls

  • 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

Development

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 spec

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

Architecture

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.

Display

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_SURFACE etc. in src/render/palette.ts) and never read canvas.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-ratio or 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.

Styling

The stylesheets in src/styles/ follow the LangChain brand system. Constraints worth knowing before editing:

  • Dark ground #030710, one accent #7fc8ff. Never pure #000 or #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.ts and are checked against tests/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.

Deployment

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.

Releases

Packages

Contributors

Languages