llm-bench-tower-building
An LLM benchmark where the test is a tower: build the tallest one you can, one block at a time, under a physics uncertainty contract.
Most LLM benchmarks grade text. This one grades a pile of blocks. The model gets a small
SDK — get_inventory, observe, place_block — and a simple
objective: stack the inventory into the tallest tower that is still standing when the physics
stops. The score is the max top-y over blocks that keep a contact chain to the ground after
everything settles. No partial credit, no rubric, no judge model. Either the tower stands or
it doesn't, and a deterministic simulator is the whole grading committee.
The uncertainty contract
The benchmark's core mechanic is a trade-off borrowed straight from physics:
sigmaX * sigmaV = K, constant per challenge. Every place_block call
takes a focus parameter between 0 and 1. Focus 1 means exact position and wild
velocity — the block lands where you asked but may kick sideways at spawn. Focus 0 means exact
velocity and wild position — it moves the way you asked but may appear off-target. Focus 0.5
splits the uncertainty evenly. Since the requested velocity is the mean of the sampled
velocity, a model can put some focus on velocity and gently press a block into place instead
of gambling on a kick. Allocating that trade-off well is the skill the benchmark exists to
measure.
How it works
Physics runs headless in src/core on rapier3d, deterministic under a seeded
RNG: validation, Gaussian sampling of the placement error, simulate-to-settle, replay log.
The three.js viewer is a viewer only — replays re-simulate from
(challengeId, seed, placement log) with the same core code the scorer used, so
what you watch is what was scored. The harness supports two conversation modes. In the default
episodic mode, each attempt starts with a fresh context; when it ends, the model distills what
it learned into a persistent notebook via update_notebook, and the next attempt
begins with only the system prompt, a harness-kept history table, and that notebook — keeping
the live context around 10–15k tokens no matter how many attempts run. Session mode keeps the
original single-conversation behavior for comparison. Models starting with
claude run on the Anthropic API; everything else uses OpenAI-compatible chat
completions, with --base-url for other providers.
What's real today
Per the repo's own status section: the core sim, ten challenges (bricks, bricks50,
bricks100, bricks50k2, bricks50k4, mixed, sparse, storm, pillars, slick), the SDK and its
spatial utilities, a scripted naive baseline agent, the replay viewer, the LLM harness in both
modes, and a local leaderboard board are all working. The test suite is vitest and covers the
RNG, the uncertainty contract, episode validation, determinism, and scoring. A full
model × challenge coverage matrix already lives in replays/ under group
cov-1, and the known tuning knobs — settle thresholds, per-challenge K — are
listed as open decisions in the project plan. The README's replays show the same model on the
same challenge producing a clean 7.3m build on one attempt and, on another, reaching 7.9m
before block 13 brought it down — then rebuilding to 4.0m from the rubble. Same weights,
different tower. That's the variance the benchmark is built to expose.
Run it yourself
It's TypeScript, MIT licensed, and the quickstart is four commands:
npm install npm test # unit + determinism + scoring tests npm run agent:naive -- --challenge bricks --seed 1 # scripted baseline npm run dev # viewer -> http://localhost:5173/src/viewer/
To bench an actual model:
npm run bench -- --model claude-fable-5 --challenge bricks --seeds 3x11
--seeds 3x11 runs three attempts on seed 11 — same seed across attempts is how
you measure in-context improvement — while --seeds 11,12,13 runs one attempt per
seed. Every run writes one replay per attempt, a JSON score summary, and a full transcript of
every turn. The repo is a math-vs-vibes project, and it runs on the same house rule as the
show: the claim is only as good as the replay.
The full README, verbatim
llm-bench-tower-building
![]() |
![]() |
| The same model, attempt 2: a clean 7.3m build. | Attempt 1: 7.9m — then block 13 brings it all down. It rebuilds to 4.0m from the rubble. |
An LLM benchmark: build the tallest tower you can by placing blocks through a small SDK — under a position/velocity uncertainty contract. You can place a block precisely, or control its velocity precisely, but not both:
sigmaX * sigmaV = K (constant per challenge)
focus = 1 -> exact position, wild velocity (block may kick sideways at spawn)
focus = 0 -> exact velocity, wild position (block may appear off-target)
focus = 0.5 -> balanced (sigmaX = sigmaX0, sigmaV = sigmaV0)
The requested velocity is the mean of the sampled velocity, so allocating some focus to velocity lets an agent "press" a block gently into place instead of gambling on a sideways kick — the trade-off is the benchmark's core skill.
Physics is simulated headlessly (rapier3d, deterministic with a seeded RNG).
three.js is a viewer only — replays re-simulate from
(challengeId, seed, placement log) with the same core code the scorer used.
Quickstart
npm install
npm test # unit + determinism + scoring tests
npm run agent:naive -- --challenge bricks --seed 1 # scripted baseline, writes replays/
npm run dev # viewer -> http://localhost:5173/src/viewer/
The viewer loads ?replay=/replays/<file>.json (defaults to the naive baseline
run). Play/pause, speed, and a scrub slider are at the bottom.
Running LLM agents
npm run bench -- --model claude-fable-5 --challenge bricks --seeds 3x11
npm run bench -- --model gpt-5.6-sol --challenge mixed --seeds 11,12,13
--seeds 3x11 = three attempts on seed 11 (same seed across attempts measures
in-context improvement); --seeds 11,12,13 = one attempt per seed. Models
starting with claude use the Anthropic API (ANTHROPIC_API_KEY /
ANTHROPIC_API_KEY_PERSONAL); everything else uses OpenAI-compatible chat
completions (OPENAI_API_KEY, --base-url for other providers).
--mode episodic (default) starts each attempt with a fresh conversation: when
an attempt ends the model distills what it learned into a persistent notebook
(update_notebook), the context resets, and the next attempt begins with only
the system prompt, the harness-kept history table, and that notebook — so the
live context stays ~10–15k tokens no matter how many attempts. --mode session
is the original single-conversation behavior, kept for comparison.
Provider knobs (env): BENCH_HTTP_TIMEOUT_MS (per-request timeout, default
300000) and BENCH_MAX_TOKENS (cap completion length) — both default off/unset
and exist for slow or non-terminating reasoning models (e.g. k3, whose server
can otherwise generate past every timeout on long planning turns).
scripts/coverage.sh runs a model × challenge coverage matrix in parallel
lanes (one concurrent run per model) — edit the lane lists to taste.
Per run the harness writes: one replay per attempt (viewable in the viewer), a
run-<label>-<challenge>-<runId>.json score summary (including mode and
notebook entries), and a full transcript-*.json of every turn. Attempts
auto-advance when the inventory is exhausted; the model can also abandon early
via next_episode.
Layout
src/core/ authoritative headless sim (no DOM): physics, uncertainty, scoring
types.ts shared interfaces (blocks, placement API, challenges, replay)
sim.ts rapier world wrapper: step, settle detection, contact queries
uncertainty.ts focus -> sigmas (sigmaX * sigmaV = K), Gaussian sampling
episode.ts validation -> sample -> simulate-to-settle -> replay log
scoring.ts tower height via contact chain to the ground
challenges.ts 10 challenges: bricks, bricks50, bricks100, bricks50k2, bricks50k4, mixed, sparse, storm, pillars, slick
src/sdk/ what an agent (LLM or scripted) drives
tools.ts tool schemas (get_inventory / observe / place_block) + SDK_DOC
utils.ts spatial helpers (rotatedExtents, stackCenterY, footprint, ...)
client.ts EpisodeClient: typed wrapper + callTool dispatch
src/viewer/ three.js replay viewer (re-simulates replays, scrub playback)
src/agents/ naive.ts — scripted baseline agent, writes replays/*.json
tests/ vitest: rng, uncertainty contract, episode validation, determinism, scoring
Placement API (summary)
place_block({
blockId: 'b1',
position: [0, 0.31, 0], // desired center, meters, y-up
yawDeg: 90, // optional, default 0
orientation: 'upright', // optional named pose: box flat|side|upright, cylinder upright|flat
quat: [0, 0, 0.7071, 0.7071], // optional full-resolution rotation (precedence over orientation/yaw)
velocity: [0, -0.3, 0], // optional desired MEAN velocity, |v| <= maxSpeed
focus: 0.6, // required: precision allocation, [0, 1]
})
// -> { ok, actual: { position, velocity, sigma: {x, v} },
// settle: { outcome, tower, spawnOverlap, spawnPenetration, ... } }
// or { ok: false, error } — validation errors are retryable and free
Score = max top-y over blocks with a contact chain to the ground, after the
whole inventory settles. Peak height is tracked separately. Blocks that fall
off the ground plane are lost. Spawning inside another block is not an error —
physics resolves it (violently); the result reports spawnOverlap.
Leaderboard
http://localhost:5173/src/board/ lists every attempt (sortable, filterable by
challenge and group) with watch links into the viewer. It reads
replays/index.json, regenerated automatically after every run or via
npm run board. Tag related runs with --group <name> to compare them as a set.
Status
Working: core sim, 10 challenges, SDK + spatial utilities, naive baseline agent,
replay viewer, LLM harness (episodic + session modes), leaderboard board. Full
model × challenge coverage matrix lives in replays/ (group cov-1); known
tuning knobs (settle thresholds, per-challenge K) are listed as open decisions
in the project plan.
License
MIT — see LICENSE.
A math vs vibes project.
Repo post · repo created 2026-07-19, posted 2026-08-06. The blocks do not care how confident the model sounded. Block 13 has been asked for comment and has fallen over.

