The prep bench's third station, built on the M12 juice lane's exact shape:
pure sim (sim/grating.ts, no three.js), a mirroring scene (scenes/grate.ts),
and a separate harness on window.__tg (dev-grate.ts). One additive DEV import
line in main.ts. Judge integration is exported and documented in-file
(grateResult / grateCriteria / GRATE_LINES / knuckleLine) — NOT wired into
game/* (that lane is owned elsewhere this stage).
The verb is a STROKE down the box-grater face: length × pressure → shred mass.
Long confident strokes land on the pile; short scrubby ones shear off the
board (mess). Two customers on one prop — cheese (parmesan) and zest (orange):
- Cheese shrinks the block as you take mass off it. As it gets SMALL your
knuckles near the teeth: knuckle-graze risk = pressure × stroke-length ×
block-proximity, so it only bites in the tense last third. Parmesan grates
clean IF cold; it softens on the bench (temp clock, warmOnBench) and soft
cheese SMEARS — output drops, the teeth CLOG (clearClog costs service time).
The butter inversion: butter wants warm, cheese wants cold.
- Zest: each of 8 orange regions has its own depth; grate one past the pith
line (ingredient gratable.pithDepth 0.35) and it sheds white pith into the
pile instead of zest. Rotate (scroll) to a fresh region — the skill is
rotation discipline. The pith idea is re-derived from the ingredient's own
pithDepth; grating deliberately does NOT import juicing (independent lanes).
Judge: The Gratings/The Zest (amount band, w1.0, docked hard per knuckle),
The Pith (zest, w1.1), The Grate (cheese clog+smear, w0.9). Bench scored
separately via result.bench, same path prep/juice use. 8 bad + 3 good lines
per criterion, plus the knuckle opener.
Tuning (first principles, then measured headless on the pure sim):
- RASP 0.12, gratable.yield 0.9 → a long stroke (len 0.9 × press 0.7) sheds
~0.068/stroke; ~7-8 strokes make an in-band pile.
- KNUCKLE_SAFE_BLOCK 0.35: proximity is zero above it, ramps to 1 at empty.
KNUCKLE_GAIN 0.6. Oranges never shrink, so zest never grazes (its danger is
pith, not knuckles) — by design.
- ZEST_DEPTH_GAIN 0.18 → a region hits the 0.35 pith line in ~3 hard strokes,
so rotate by then. ZEST_MASS_FRAC 0.4 (zest is a whisper vs a cheese pile).
- SPRAY_GAIN 0.9 on (1-length)^2 — spray climbs steeply as strokes shorten.
- SMEAR_LOSS 0.8, CLOG_GAIN 0.35 × grater.clogProne (box 0.7 / microplane 1.3).
Verified: tsc --noEmit clean, npm run build clean. Sim driven headless with
the scene's own seed (0x6ac0f). __tg calls and expected numbers:
__tg.enter({ingredient:'parmesan'}); __tg.stroke(8,0.7,0.9); __tg.stats()
→ gratings≈0.61 "a good pile", blockLeft≈0.38, knuckleHits 0, clog 0;
The Gratings 1.0, The Grate 1.0.
__tg.enter({ingredient:'parmesan'}); __tg.stroke(12,0.6,0.25); __tg.stats()
→ scrub: gratings≈0.10 "a dusting", stray≈0.10 off the board, block barely
touched (0.81); The Gratings 0.24.
__tg.enter({ingredient:'parmesan',softness:1}); __tg.stroke(8,0.8,0.9); __tg.stats()
→ soft: clog 1.0, smear≈0.61, gratings≈0.05; The Grate 0.0 "clogged solid".
__tg.enter({ingredient:'parmesan',block:0.25}); __tg.stroke(8,0.9,0.95); __tg.stats()
→ danger: knuckleHits 4, blockLeft≈0.02, gratings≈0.24; The Gratings 0.008.
__tg.enter({ingredient:'parmesan',block:0.25}); __tg.stroke(8,0.9,0.35); __tg.stats()
→ ease off: knuckleHits 0, gratings≈0.22; The Gratings 0.54. The lesson pays.
__tg.enter({ingredient:'orange'}); __tg.stroke(5,0.6,0.8) with rotate every 2
→ gratings≈0.12 in band, pithFrac 0; The Zest 1.0, The Pith 1.0.
__tg.enter({ingredient:'orange'}); __tg.stroke(9,0.9,0.9); __tg.stats() // no rotation
→ pithFrac≈0.74 "74% pith"; The Pith 0.0.
(Scene HUD, pile mound, red knuckle flash, and scroll-rotate are visual — the
reviewer drives __tg and screenshots; the numbers above come from the pure sim
the scene feeds.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|---|---|---|
| .claude | ||
| public/assets | ||
| scripts | ||
| src | ||
| .gitignore | ||
| index.html | ||
| MASTERPLAN.md | ||
| OPUS-BUILD-BRIEF-2.md | ||
| OPUS-BUILD-BRIEF.md | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
TOASTSIM
Bread goes in. You are judged.
A browser game about making toast, where the inputs are analog and the output is scored by a man who has been doing this for thirty-one years.
npm install
npm run dev # http://localhost:5173
The loop
Order → toast → drawer → spread → verdict → next day.
- The ticket tells you what someone wants: how dark, which spread, how much, which utensil, and whether they'll tolerate burnt bits.
- The toaster has a dial and a lever and no timer. You go by smell.
- The drawer makes you find the right piece of cutlery in a tangle of similar ones — while your toast goes cold.
- The bench is where the game actually lives. See below.
- The judge scores nine criteria and has something to say about the worst one.
The mechanic
One control — the knife's angle (mouse wheel) — and three behaviours fall out of it:
| angle | contact | what happens |
|---|---|---|
| flat | wide, low pressure | spreads — or tears the bread, if the spread is stiffer than the pressure you're allowed to make |
| steep | narrow, high pressure | scrapes spread back off, or lifts char off burnt toast |
| steep + fast, on bread with nothing left to take | gouges |
The trap: pressure comes from steepness, but past the scrape threshold a steep knife stops spreading. So a spread can demand more pressure than spreading mode can physically give:
| situation | yield | angle needed | scrape cliff at 0.62 |
|---|---|---|---|
| fridge butter / cold toast | 0.72 | 0.75 | impossible |
| bench butter / cold toast | 0.54 | 0.57 | one click from disaster |
| fridge butter / fresh toast | 0.36 | 0.38 | fine |
| soft butter / fresh toast | 0.16 | 0.12 | dream mode |
Cold toast with hard butter cannot be spread at any angle. The way out isn't technique — it's not dawdling in the drawer. That's why the drawer is there. The pressure gauge draws both marks so you can watch the gold sit past the red and understand exactly why you're losing.
Scraping is the other tension: it rescues burnt toast and wrecks evenness doing it (char 99.9% → 54.5%, evenness 0.029 → 0.226). The judge notices both.
And MITEY is its own skill: spread it on, then take almost all of it back off. He wants to see the toast through it.
Peanut butter comes smooth or crunchy, and both are natural — the jar
separates while it sits. The oil rises; the paste underneath dries out. An
unstirred first dip is a slick (flows at almost no pressure, goes on thin and
shiny), the dips after it are grout (fights like cold butter). Swirl the knife
in the jar — about two and a half circles — to mix it back, and yes, stirring
costs time, and time costs toast warmth. The judge reads the mass-weighted mean
and spread of the consistency that actually landed: oil 0.50 ±0.00 — just right versus oil 0.54 ±0.23 — slick here, grout there, because a slick strip
and a grout strip average to "correct" and nobody eats the average. Crunchy
additionally has chunks, and the chunks are judged like rind (below).
Marmalade adds a second axis: the rind. Bits drop off the blade per unit of stroke distance, so a dabbing knife makes a pile and a moving knife lays a trail — and because the shed rate scales with what's left on the blade, one dip can't cover the slice. The judge scores the scatter with the Clark–Evans nearest-neighbour index (R ≈ 0 one clump, 1 random, above 1 deliberately even) and quotes it on the card: a corner dab reads "one clump (R 0.24)", three well-placed dips read "evenly strewn (R 1.17)". Scraping picks rind back up, so a clump can be argued with rather than started over.
The bread
Same dial, same 18 seconds, four breads:
| bread | 4s | 9s | 18s | char |
|---|---|---|---|---|
| white | 0.22 | 0.54 | 1.08 | 93% |
| multigrain | 0.20 | 0.52 | 1.06 | 92% |
| raisin | 0.30 | 0.76 | 1.14 | 99.8% |
| sourdough | 0.11 | 0.33 | 0.74 | 14% |
Sourdough's water has to boil off before the crust can brown, so it stalls and then accelerates hardest. Raisin bread is sugar: it browns early and burns early.
How it's built
- Vite + TypeScript + three.js, Rapier for the drawer. No framework; the UI is a DOM overlay over one WebGL canvas.
- The slice is the whole game's canvas: an extruded loaf silhouette with four
128×128 scalar fields —
browning,dryness,spread,damage— packed into one RGBA8 texture per frame and composited by a custom shader. Everything the player does writes a field; everything the judge reads is a statistic over one. - Cutlery is procedural on purpose. The drawer asks you to tell a dessert fork from a dinner fork, and that's only fair if the silhouettes are authored.
- Physics: compound boxes, never trimeshes. Grabbing is a capped PD spring, so a snagged piece fights what's lying on it instead of tunnelling through.
Layout
src/
core/ app + loop, input, seeded rng, 2D field, synthesised audio
sim/ bread, slice (geometry/fields/shader), toasting, spreads,
spreading (the star mechanic), cutlery
scenes/ kitchen (toast + spread), drawer (rapier), judge, props
game/ game loop, orders, judging rubric, the judge's lines
ui/ hud, title, cutlery silhouettes
dev.ts dev-only harness — drives real gestures deterministically
Dev harness
The game is driven by mouse gestures over a 3D scene, which makes "does it work"
hard to answer by inspection. In a dev build, window.__t drives the real code
paths at a chosen dt:
__t.toast(10, 6) // lever down, 10s at power 6, pop, land
__t.spread(9, 0.55) // dip and raster the slice at 0.55 units/sec
__t.stats() // every field statistic the judge will read
It's also the escape hatch for headless browsers that never fire rAF —
everything calls app.step(dt) directly.
Assets
Every asset was generated on-device and free via MODELBEAST, and is reproducible:
./scripts/gen-assets.sh # fixed seeds; skips anything already present
flux_local → bg_remove_local → hunyuan3d_mlx. The judge's five expressions
are one fixed seed and one fixed description with only the expression phrase
varying, which is why he stays the same man while his face falls.
The toaster is procedural rather than the generated GLB — it needs a lever we can actually drive, and a dial that turns.