M5+M6: content, generated assets, title, sound, perf

Content (verified — same dial, same 18s, four breads that disagree):

  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 stalls at half the rate then accelerates hardest (x3.0 vs white's
x2.45) as its water boils off; raisin browns 40% faster and is essentially
carbon by 18s. Seven handwritten days then procedural, day persisted.

Assets, all generated on-device and free, all reproducible from fixed seeds:
- the judge's five expressions (one seed, one description, only the expression
  phrase varying — he stays the same man while his face falls)
- title art, wooden bench, blurred kitchen backdrop
- butter dish / peanut butter / MITEY jars as GLBs, normalised on load
  (recentre, scale to fit, sit on y=0) rather than hand-fixed, so regenerating
  them doesn't mean re-fixing them

The toaster stays procedural: the generated one is prettier but this one has a
lever we can drive and a dial that turns. Judgment call, per the brief.

Sound is synthesised, no samples: clatter is driven by real Rapier contact-force
events and scaled by force, which is the only way a drawer of cutlery can sound
like one. Plus lever clunk, knife scrape pitched by pressure, and the stamp.

Perf, measured (16.7ms is the 60fps budget):
  kitchen 0.99ms/step · drawer 0.12ms · drawer while dragging 0.21ms

Production build: 2.87MB / 1.0MB gzipped, mostly Rapier's inlined wasm + three.

Textures carry colorSpace = SRGBColorSpace explicitly — same class of bug as the
albedo gamma fix in M3, and it washes them out silently if you forget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
monster 2026-07-16 22:21:06 +10:00
parent 1ba66f0cdf
commit 5b09f23c7e
7 changed files with 297 additions and 49 deletions

127
README.md Normal file
View File

@ -0,0 +1,127 @@
# 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.
```bash
npm install
npm run dev # http://localhost:5173
```
## The loop
**Order → toast → drawer → spread → verdict → next day.**
1. **The ticket** tells you what someone wants: how dark, which spread, how much,
which utensil, and whether they'll tolerate burnt bits.
2. **The toaster** has a dial and a lever and no timer. You go by smell.
3. **The drawer** makes you find the right piece of cutlery in a tangle of
similar ones — while your toast goes cold.
4. **The bench** is where the game actually lives. See below.
5. **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.
## 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`:
```js
__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](../MODELBEAST/AGENTS.md), and is reproducible:
```bash
./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.

View File

@ -9,6 +9,7 @@ import { coolStep } from '../sim/toasting';
import { judge, type Grade } from './judging';
import { DAYS, proceduralOrder, nameBrowning, type Order } from './orders';
import { el, Panel } from '../ui/hud';
import { Title } from '../ui/title';
const SAVE_KEY = 'toastsim.save';
@ -69,7 +70,14 @@ export class Game {
this.drawer.warmth = k.currentSlice.warmth;
});
this.beginDay();
// Sit on the title until the player clicks — which also gives us the user
// gesture the browser wants before any audio is allowed to make a sound.
this.kitchen.root.visible = false;
this.ticket.hide();
new Title(this.save.day, () => {
audio.resume();
this.beginDay();
});
}
get currentOrder(): Order {

49
src/scenes/assets.ts Normal file
View File

@ -0,0 +1,49 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
const cache = new Map<string, Promise<THREE.Group>>();
/**
* Load a generated GLB and make it usable without touching the file.
*
* Meshes that came out of an image-to-3D pipeline arrive at an arbitrary scale,
* with an arbitrary pivot, sitting wherever the mesher felt like so every one
* is normalised on load: recentre on its own bounding box, scale so the longest
* axis is `size`, and drop it so it sits on y=0. Hand-fixing the assets instead
* would mean re-fixing them every time they're regenerated.
*/
export function loadProp(url: string, size: number): Promise<THREE.Group> {
const key = `${url}@${size}`;
let p = cache.get(key);
if (!p) {
p = loader.loadAsync(url).then((gltf) => {
const root = gltf.scene;
const box = new THREE.Box3().setFromObject(root);
const dim = box.getSize(new THREE.Vector3());
const centre = box.getCenter(new THREE.Vector3());
const longest = Math.max(dim.x, dim.y, dim.z) || 1;
const s = size / longest;
// Wrap it: scaling the loaded scene directly fights whatever transforms
// the exporter already baked into it.
const wrap = new THREE.Group();
root.position.set(-centre.x, -box.min.y, -centre.z);
root.scale.setScalar(1);
const inner = new THREE.Group();
inner.add(root);
inner.scale.setScalar(s);
wrap.add(inner);
root.traverse((o) => {
if ((o as THREE.Mesh).isMesh) {
o.castShadow = true;
o.receiveShadow = true;
}
});
return wrap;
});
cache.set(key, p);
}
return p;
}

View File

@ -167,20 +167,31 @@ export function makePlate(): THREE.Group {
export function makeBench(): THREE.Group {
const g = new THREE.Group();
const loader = new THREE.TextureLoader();
const wood = loader.load('/assets/img/bench_wood.png');
wood.wrapS = wood.wrapT = THREE.RepeatWrapping;
wood.repeat.set(4, 2);
// The texture was generated as artwork, i.e. sRGB. Saying so is what keeps it
// from washing out once it's lit.
wood.colorSpace = THREE.SRGBColorSpace;
wood.anisotropy = 8;
const top = new THREE.Mesh(
new THREE.BoxGeometry(20, 0.5, 9),
new THREE.MeshStandardMaterial({ color: 0x9a6134, roughness: 0.72 }),
new THREE.MeshStandardMaterial({ map: wood, color: 0xbb9068, roughness: 0.68 }),
);
top.position.y = -0.25;
top.receiveShadow = true;
g.add(top);
// splashback, so the scene has a horizon instead of fog
// Splashback: a blurred kitchen so the scene has a horizon instead of fog.
const back = loader.load('/assets/img/kitchen_backdrop.png');
back.colorSpace = THREE.SRGBColorSpace;
const wall = new THREE.Mesh(
new THREE.BoxGeometry(20, 7, 0.3),
new THREE.MeshStandardMaterial({ color: 0x5d5348, roughness: 0.9 }),
new THREE.PlaneGeometry(22, 8),
new THREE.MeshBasicMaterial({ map: back, color: 0x6a6258 }),
);
wall.position.set(0, 3.5, -4.4);
wall.receiveShadow = true;
wall.position.set(0, 3.2, -4.4);
g.add(wall);
return g;
}

View File

@ -1,5 +1,6 @@
import * as THREE from 'three';
import { makeCutleryMesh, type Tool } from '../sim/cutlery';
import { loadProp } from './assets';
import type { SpreadDef } from '../sim/spreads';
/**
@ -64,50 +65,29 @@ export class KnifeRig {
}
}
/** The pot you dip into. One per order — you only ever get one spread. */
/**
* The pot you dip into one per order, because you only ever get one spread.
*
* The vessel is a generated GLB; the thing you actually dip into is an invisible
* box at its mouth. Raycasting the generated mesh directly would be at the mercy
* of whatever the mesher produced, and the dip target needs to be exactly where
* the player expects it.
*/
export function makeSpreadPot(def: SpreadDef): { root: THREE.Group; hit: THREE.Mesh } {
const g = new THREE.Group();
const glass = new THREE.MeshStandardMaterial({
color: 0xdfe6e6,
roughness: 0.12,
metalness: 0.02,
transparent: true,
opacity: 0.42,
});
const contents = new THREE.MeshStandardMaterial({
color: new THREE.Color(def.color[0], def.color[1], def.color[2]),
roughness: def.id === 'peanut' ? 0.85 : 0.4,
});
const isButter = def.id === 'butter';
if (def.id === 'butter') {
const dish = new THREE.Mesh(new THREE.BoxGeometry(1.15, 0.16, 0.75), glass);
dish.position.y = 0.08;
g.add(dish);
const block = new THREE.Mesh(new THREE.BoxGeometry(0.78, 0.3, 0.44), contents);
block.position.y = 0.29;
block.castShadow = true;
g.add(block);
return { root: g, hit: block };
}
const jar = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.4, 0.66, 24), glass);
jar.position.y = 0.33;
g.add(jar);
const fill = new THREE.Mesh(new THREE.CylinderGeometry(0.36, 0.34, 0.46, 24), contents);
fill.position.y = 0.27;
g.add(fill);
const surface = new THREE.Mesh(new THREE.CylinderGeometry(0.36, 0.36, 0.02, 24), contents);
surface.position.y = 0.5;
g.add(surface);
const label = new THREE.Mesh(
new THREE.CylinderGeometry(0.425, 0.425, 0.3, 24, 1, true),
new THREE.MeshStandardMaterial({
color: def.id === 'mitey' ? 0xc8102e : 0xf2e2c0,
roughness: 0.7,
side: THREE.DoubleSide,
}),
// material.visible = false, not object.visible = false: the renderer skips it
// either way, but the raycaster still needs a live object to hit.
const hit = new THREE.Mesh(
new THREE.BoxGeometry(isButter ? 0.8 : 0.62, 0.08, isButter ? 0.5 : 0.62),
new THREE.MeshBasicMaterial({ visible: false }),
);
label.position.y = 0.3;
g.add(label);
return { root: g, hit: surface };
hit.position.y = isButter ? 0.34 : 0.56;
g.add(hit);
const file = isButter ? 'butter_dish' : def.id === 'peanut' ? 'pb_jar' : 'mitey_jar';
void loadProp(`/assets/models/${file}.glb`, isButter ? 1.5 : 1.15).then((prop) => g.add(prop));
return { root: g, hit };
}

View File

@ -514,3 +514,50 @@ canvas#c {
color: var(--ink-dim);
text-shadow: 0 2px 10px #000;
}
/* ---- title ---- */
.title {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at 50% 45%, #241a15 0%, #0d0a08 75%);
}
.title-wrap {
display: flex;
align-items: center;
gap: 40px;
max-width: 900px;
padding: 24px;
}
.title-art {
width: 380px;
max-width: 44vw;
border-radius: 12px;
filter: drop-shadow(0 20px 44px rgba(0, 0, 0, 0.7));
}
.title-txt h1 {
font-size: 62px;
letter-spacing: 0.05em;
line-height: 1;
color: var(--paper);
}
.title-sub {
margin: 10px 0 26px;
font-size: 16px;
font-style: italic;
color: var(--ink-dim);
}
.title-keys {
margin-top: 26px;
font-size: 11px;
line-height: 1.9;
letter-spacing: 0.08em;
color: #6d6155;
}

26
src/ui/title.ts Normal file
View File

@ -0,0 +1,26 @@
import { el, Panel } from './hud';
/** The front door. One button, one piece of art, one joke. */
export class Title {
private panel: Panel;
constructor(day: number, onStart: () => void) {
this.panel = new Panel('title');
const wrap = el('div', 'title-wrap', this.panel.root);
const art = el('img', 'title-art', wrap);
art.src = '/assets/img/title_art.png';
art.alt = '';
const txt = el('div', 'title-txt', wrap);
el('h1', undefined, txt, 'TOASTSIM');
el('p', 'title-sub', txt, 'Bread goes in. You are judged.');
const btn = el('button', 'btn', txt, day > 1 ? `RESUME — DAY ${day}` : 'START DAY 1');
btn.addEventListener('click', () => {
this.panel.dispose();
onStart();
});
const keys = el('div', 'title-keys', txt);
el('div', undefined, keys, 'SPACE — lever down, then pop');
el('div', undefined, keys, 'DRAG — spread · WHEEL — tilt the knife');
el('div', undefined, keys, 'ENTER — serve it to him');
}
}