M7: marmalade — rind, and a judge who scores its distribution

A particulate spread. The jelly is ordinary rheology; the rind is discrete
solids that ride the knife and drop off per unit of stroke DISTANCE, not per
second — a dabbing knife makes a pile, a moving knife lays a trail, and since
the shed rate scales with what's left on the blade, one dip cannot cover the
slice. Even rind takes several dips placed with intent. Scraping picks bits
back up (60% survive the blade), so a clump can be argued with.

The judge scores the scatter with the Clark–Evans nearest-neighbour index over
the bread's own area, and quotes it: R near 0 is one clump, 1 is random, above
1 is deliberate. Verified end to end on day 8:

  dab in a corner      8 bits   "one clump (R 0.24)"        drags grade to B
  three placed dips    24 bits  "evenly strewn (R 1.17)"    A, 8.9/10

First cut shed per-second; even careful strokes dumped the whole load in the
first half-second and scored "clumped (R 0.50)". Distance-based shedding is
what makes evenness the reward for technique rather than luck.

Rind renders as instanced chunks parented to the slice mesh, so it rides the
pop, the landing, and the pedestal turn. Points, not a field: the judge wants
nearest-neighbour statistics, and those want points.

Same sRGB trap as M3, new spot: the instanced material's colour was passed as
raw floats, which the renderer reads as linear — candied orange rendered as
pale butter. setRGB(..., SRGBColorSpace).

New content: day 8 handwritten (Deidre wants a piece in every bite), marmalade
in the procedural pool, five rind-district lines for the judge, and a marmalade
jar GLB generated on-device like everything else, with a plain-glass fallback
while it loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
monster 2026-07-16 22:45:57 +10:00
parent 5b09f23c7e
commit 70922bd855
10 changed files with 309 additions and 9 deletions

View File

@ -55,6 +55,15 @@ 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 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. wants to see the toast *through* it.
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 ClarkEvans
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 ## The bread
Same dial, same 18 seconds, four breads: Same dial, same 18 seconds, four breads:

Binary file not shown.

View File

@ -54,7 +54,10 @@ export function installDevHarness(app: App, game: Game): void {
}, },
dip() { dip() {
point(3.15, 0.29, 0.45, true); const hit = (kitchen as unknown as { potHit: THREE.Mesh | null }).potHit;
const wp = new THREE.Vector3(3.15, 0.29, 0.45);
hit?.getWorldPosition(wp);
point(wp.x, wp.y, wp.z, true);
run(4); run(4);
app.input.down = false; app.input.down = false;
app.step(1 / 60); app.step(1 / 60);

View File

@ -89,6 +89,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
weight: 1.2, weight: 1.2,
detail: d.mean < 0.002 ? 'intact' : `torn over ${Math.round(slice.damage.fraction(slice.mask, (v) => v > 0.02) * 100)}%`, detail: d.mean < 0.002 ? 'intact' : `torn over ${Math.round(slice.damage.fraction(slice.mask, (v) => v > 0.02) * 100)}%`,
}, },
...(def.particles ? [rindCriterion(slice, def.particles.name)] : []),
{ {
key: 'amount', key: 'amount',
label: 'The Right Amount', label: 'The Right Amount',
@ -136,6 +137,57 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
}; };
} }
/**
* How evenly the rind is strewn, scored with the ClarkEvans index: the mean
* nearest-neighbour distance between bits, over what that distance would be if
* the same number of bits were scattered at random. R near 0 = one clump,
* R = 1 = random scatter, R above 1 = deliberately even. The judge accepts
* random; he rewards deliberate.
*/
function rindCriterion(slice: Slice, name: string): Criterion {
const pts = slice.rind;
const n = pts.length;
if (n < 4) {
return {
key: 'rind',
label: cap(name),
score: n === 0 ? 0.05 : 0.3,
weight: 1.1,
detail: n === 0 ? `no ${name} at all` : `only ${n} bits of ${name}`,
};
}
// Mask area in UV units — the bread is the study region, not the unit square.
const area = slice.mask.data.reduce((a, v) => a + (v > 0.5 ? 1 : 0), 0) / (slice.mask.n * slice.mask.n);
let sum = 0;
for (let i = 0; i < n; i++) {
let best = Infinity;
for (let j = 0; j < n; j++) {
if (i === j) continue;
const du = pts[i].u - pts[j].u;
const dv = pts[i].v - pts[j].v;
const d2 = du * du + dv * dv;
if (d2 < best) best = d2;
}
sum += Math.sqrt(best);
}
const observed = sum / n;
const expected = 0.5 * Math.sqrt(area / n);
const R = observed / expected;
const score = clamp01(R / 0.95);
const word = R < 0.45 ? 'one clump' : R < 0.75 ? 'clumped' : R < 1.05 ? 'scattered' : 'evenly strewn';
return {
key: 'rind',
label: cap(name),
score,
weight: 1.1,
detail: `${n} bits, ${word} (R ${R.toFixed(2)})`,
};
}
function cap(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function spreadStdevOverCovered(slice: Slice, floor: number): number { function spreadStdevOverCovered(slice: Slice, floor: number): number {
const sp = slice.spread.data; const sp = slice.spread.data;
const m = slice.mask.data; const m = slice.mask.data;

View File

@ -106,6 +106,20 @@ const BANK: Bank = {
'The quantity is correct. Thank you.', 'The quantity is correct. Thank you.',
], ],
}, },
rind: {
bad: [
'The rind is all in one postcode.',
'You have made a toast with a rind district.',
'Every piece of rind, huddled together for warmth. Spread them OUT.',
'One bite of this is all rind. The others are an apology.',
'There is no rind on this. Did you eat it? You ate it.',
],
good: [
'The rind is evenly strewn. That takes patience, and you had it.',
'Every bite gets its piece of rind. That is the entire craft.',
'Rind distribution: textbook. I did not expect that today.',
],
},
tool: { tool: {
bad: [ bad: [
'You did this with a {tool}. I can tell. Everyone can tell.', 'You did this with a {tool}. I can tell. Everyone can tell.',

View File

@ -135,12 +135,25 @@ export const DAYS: Order[] = [
who: 'the inspector himself', who: 'the inspector himself',
text: "Thick sourdough, well done, and a *scrape* of MITEY. I want to see the toast through it.", text: "Thick sourdough, well done, and a *scrape* of MITEY. I want to see the toast through it.",
}, },
{
day: 8,
bread: 'white',
spread: 'marmalade',
amount: 'normal',
browning: 0.55,
browningName: 'golden',
noChar: true,
tool: 'spreader',
butterSoftness: 0.5,
who: 'Deidre',
text: 'Marmalade! And mind the rind, love — a piece in every bite, not a pile in one corner.',
},
]; ];
/** Days 8+ — keep going, with everything cranked. */ /** Days 9+ — keep going, with everything cranked. */
export function proceduralOrder(day: number, rand: () => number): Order { export function proceduralOrder(day: number, rand: () => number): Order {
const breads: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough']; const breads: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough'];
const spreads: SpreadId[] = ['butter', 'peanut', 'mitey']; const spreads: SpreadId[] = ['butter', 'peanut', 'mitey', 'marmalade'];
const amounts: AmountClass[] = ['thin', 'normal', 'thick']; const amounts: AmountClass[] = ['thin', 'normal', 'thick'];
const tools: ToolId[] = [ const tools: ToolId[] = [
'butter_knife', 'butter_knife',
@ -155,7 +168,8 @@ export function proceduralOrder(day: number, rand: () => number): Order {
]; ];
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)]; const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
const spread = pick(spreads); const spread = pick(spreads);
const amount: AmountClass = spread === 'mitey' ? (rand() < 0.75 ? 'thin' : 'normal') : pick(amounts); const amount: AmountClass =
spread === 'mitey' ? (rand() < 0.75 ? 'thin' : 'normal') : pick(amounts);
const browning = 0.3 + rand() * 0.5; const browning = 0.3 + rand() * 0.5;
return { return {
day, day,
@ -167,7 +181,7 @@ export function proceduralOrder(day: number, rand: () => number): Order {
noChar: rand() < 0.8, noChar: rand() < 0.8,
tool: pick(tools), tool: pick(tools),
// It only gets colder from here. // It only gets colder from here.
butterSoftness: Math.max(0, 0.4 - (day - 8) * 0.08) * rand(), butterSoftness: Math.max(0, 0.4 - (day - 9) * 0.08) * rand(),
who: pick(['Deidre', 'Ray', 'a regular', 'someone new', 'the inspector himself']), who: pick(['Deidre', 'Ray', 'a regular', 'someone new', 'the inspector himself']),
text: '', text: '',
}; };

View File

@ -86,8 +86,36 @@ export function makeSpreadPot(def: SpreadDef): { root: THREE.Group; hit: THREE.M
hit.position.y = isButter ? 0.34 : 0.56; hit.position.y = isButter ? 0.34 : 0.56;
g.add(hit); g.add(hit);
const file = isButter ? 'butter_dish' : def.id === 'peanut' ? 'pb_jar' : 'mitey_jar'; const file =
void loadProp(`/assets/models/${file}.glb`, isButter ? 1.5 : 1.15).then((prop) => g.add(prop)); isButter ? 'butter_dish'
: def.id === 'peanut' ? 'pb_jar'
: def.id === 'marmalade' ? 'marmalade_jar'
: 'mitey_jar';
loadProp(`/assets/models/${file}.glb`, isButter ? 1.5 : 1.15)
.then((prop) => g.add(prop))
.catch(() => {
// Asset still generating (or missing): a plain glass jar of the right
// colour keeps the order playable.
const jar = new THREE.Mesh(
new THREE.CylinderGeometry(0.42, 0.4, 0.66, 24),
new THREE.MeshStandardMaterial({
color: 0xdfe6e6,
roughness: 0.12,
transparent: true,
opacity: 0.42,
}),
);
jar.position.y = 0.33;
const fill = new THREE.Mesh(
new THREE.CylinderGeometry(0.36, 0.34, 0.5, 24),
new THREE.MeshStandardMaterial({
color: new THREE.Color(def.color[0], def.color[1], def.color[2]),
roughness: 0.35,
}),
);
fill.position.y = 0.29;
g.add(jar, fill);
});
return { root: g, hit }; return { root: g, hit };
} }

View File

@ -9,6 +9,9 @@ export const FIELD_N = 128;
/** Past this, the surface has stopped being toast and started being charcoal. */ /** Past this, the surface has stopped being toast and started being charcoal. */
export const CHAR_THRESHOLD = 0.85; export const CHAR_THRESHOLD = 0.85;
/** Hard cap on rind bits — also the instanced mesh's allocation. */
export const RIND_MAX = 96;
/** /**
* One slice of bread: the mesh, the four scalar fields the whole game reads and * One slice of bread: the mesh, the four scalar fields the whole game reads and
* writes, and the shader that turns them into something appetising (or not). * writes, and the shader that turns them into something appetising (or not).
@ -44,6 +47,15 @@ export class Slice {
readonly sizeZ: number; readonly sizeZ: number;
readonly halfThickness: number; readonly halfThickness: number;
/**
* Solid bits sitting on the spread (marmalade rind), in UV space. Discrete
* points rather than a fifth field: the judge scores their *distribution*, and
* nearest-neighbour statistics want points, not a raster.
*/
readonly rind: { u: number; v: number }[] = [];
private rindMesh: THREE.InstancedMesh | null = null;
private rindDirty = false;
private tex: THREE.DataTexture; private tex: THREE.DataTexture;
private texData: Uint8Array; private texData: Uint8Array;
private dirty = true; private dirty = true;
@ -95,12 +107,91 @@ export class Slice {
return this.mesh.position.y + this.halfThickness; return this.mesh.position.y + this.halfThickness;
} }
/** Drop a bit of rind at (u,v). It rides the slice from then on. */
addRind(u: number, v: number): void {
if (this.rind.length >= RIND_MAX) return;
this.rind.push({ u, v });
this.rindDirty = true;
}
/** Scrape rind off within `radius` (UV units) of (u,v). Returns how many went. */
removeRindNear(u: number, v: number, radius: number): number {
const r2 = radius * radius;
let removed = 0;
for (let i = this.rind.length - 1; i >= 0; i--) {
const p = this.rind[i];
const du = p.u - u;
const dv = p.v - v;
if (du * du + dv * dv <= r2) {
this.rind.splice(i, 1);
removed++;
}
}
if (removed) this.rindDirty = true;
return removed;
}
clearRind(): void {
if (this.rind.length) this.rindDirty = true;
this.rind.length = 0;
}
/**
* Rebuild the rind instances. The instanced mesh is a child of the slice mesh
* and positioned in its local space, so rind rides along when the toast flies,
* lands, and turns on the judge's pedestal.
*/
private syncRind(): void {
if (!this.rindDirty) return;
this.rindDirty = false;
if (!this.rindMesh) {
if (this.rind.length === 0) return;
const def = this.spreadDef?.particles;
const s = def?.size ?? 0.05;
const geo = new THREE.BoxGeometry(s * 1.6, s * 0.55, s * 0.9);
// The palette is authored in sRGB; passing the floats raw would have the
// material read them as linear and render candied orange as pale butter —
// the same trap as the slice shader's albedo.
const col = new THREE.Color().setRGB(
...(def?.color ?? ([0.6, 0.25, 0.05] as [number, number, number])),
THREE.SRGBColorSpace,
);
const mat = new THREE.MeshStandardMaterial({ color: col, roughness: 0.38 });
this.rindMesh = new THREE.InstancedMesh(geo, mat, RIND_MAX);
this.rindMesh.castShadow = true;
this.mesh.add(this.rindMesh);
}
const m = new THREE.Matrix4();
const q = new THREE.Quaternion();
const pos = new THREE.Vector3();
const scl = new THREE.Vector3();
const axis = new THREE.Vector3(0, 1, 0);
for (let i = 0; i < this.rind.length; i++) {
const p = this.rind[i];
pos.set(
(p.u - 0.5) * this.sizeX,
this.halfThickness + 0.008,
(0.5 - p.v) * this.sizeZ,
);
// Deterministic per-index jitter so the pieces read as strewn, not stamped.
const h = Math.sin(i * 127.1) * 43758.5453;
const r = h - Math.floor(h);
q.setFromAxisAngle(axis, r * Math.PI * 2);
scl.setScalar(0.75 + r * 0.6);
m.compose(pos, q, scl);
this.rindMesh.setMatrixAt(i, m);
}
this.rindMesh.count = this.rind.length;
this.rindMesh.instanceMatrix.needsUpdate = true;
}
get uniforms() { get uniforms() {
return this.material.uniforms; return this.material.uniforms;
} }
/** Push the sim fields into the GPU texture. Cheap: 64KB. */ /** Push the sim fields into the GPU texture. Cheap: 64KB. */
sync(): void { sync(): void {
this.syncRind();
if (!this.dirty) return; if (!this.dirty) return;
const d = this.texData; const d = this.texData;
const b = this.browning.data; const b = this.browning.data;
@ -163,6 +254,10 @@ export class Slice {
} }
dispose(): void { dispose(): void {
if (this.rindMesh) {
this.rindMesh.geometry.dispose();
(this.rindMesh.material as THREE.Material).dispose();
}
this.mesh.geometry.dispose(); this.mesh.geometry.dispose();
this.material.dispose(); this.material.dispose();
this.tex.dispose(); this.tex.dispose();

View File

@ -41,6 +41,10 @@ export interface Knife {
angle: number; angle: number;
/** Spread carried on the blade, in mean-thickness units (same as the field). */ /** Spread carried on the blade, in mean-thickness units (same as the field). */
load: number; load: number;
/** Solid bits (rind) riding the blade, in whole pieces. */
rindLoad: number;
/** Fractional shed accumulator — pieces drop one at a time. */
rindFrac: number;
u: number; u: number;
v: number; v: number;
hasPrev: boolean; hasPrev: boolean;
@ -65,11 +69,15 @@ export interface StrokeFx {
export function newKnife(): Knife { export function newKnife(): Knife {
// Starts inside the window where warm-toast butter actually flows: the very // Starts inside the window where warm-toast butter actually flows: the very
// first stroke of the game shouldn't be a mysterious failure. // first stroke of the game shouldn't be a mysterious failure.
return { angle: 0.45, load: 0, u: 0.5, v: 0.5, hasPrev: false }; return { angle: 0.45, load: 0, rindLoad: 0, rindFrac: 0, u: 0.5, v: 0.5, hasPrev: false };
} }
export function dip(knife: Knife, def: SpreadDef): void { export function dip(knife: Knife, def: SpreadDef): void {
knife.load = def.pickup; knife.load = def.pickup;
if (def.particles) {
knife.rindLoad = def.particles.perDip;
knife.rindFrac = 0;
}
} }
/** Cheap per-texel hash, for blotchy tools. */ /** Cheap per-texel hash, for blotchy tools. */
@ -183,6 +191,26 @@ function contact(
}); });
knife.load = Math.max(0, massLeft / (slice.mask.n * slice.mask.n)); knife.load = Math.max(0, massLeft / (slice.mask.n * slice.mask.n));
// Suspended solids drop off the blade as it works — per unit of stroke
// DISTANCE, not per second. That's the whole feel: a moving knife lays an
// even trail, a dabbing knife piles everything into one spot, and because
// the rate scales with what's left on the blade, one dip can't cover the
// slice — even rind takes several dips, placed with intent.
if (def.particles && knife.rindLoad > 0.01) {
knife.rindFrac += knife.rindLoad * def.particles.shedRate * speed * dt;
while (knife.rindFrac >= 1 && knife.rindLoad >= 1) {
knife.rindFrac -= 1;
knife.rindLoad -= 1;
const h1 = blotchAt(slice.rind.length * 7 + 1);
const h2 = blotchAt(slice.rind.length * 13 + 5);
const ang = h1 * Math.PI * 2;
const dist = Math.sqrt(h2) * radius * 0.8;
const ru = u + Math.cos(ang) * dist;
const rv = v + Math.sin(ang) * dist;
if (slice.mask.sample(ru, rv) > 0.5) slice.addRind(ru, rv);
}
}
if (!flowing && moving > 0.05) { if (!flowing && moving > 0.05) {
const tear = TEAR_RATE * deficit * moving * tool.tearProne * dt; const tear = TEAR_RATE * deficit * moving * tool.tearProne * dt;
slice.damage.brush(u, v, radius, (i, w) => { slice.damage.brush(u, v, radius, (i, w) => {
@ -197,6 +225,11 @@ function contact(
} }
// --- scrape --- // --- scrape ---
// The blade takes rind with it too — some of it survives the trip and can be
// redistributed, which is how you fix a clump without starting over.
const rindOff = slice.removeRindNear(u, v, radius * 1.15);
if (rindOff > 0) knife.rindLoad += rindOff * 0.6;
let removedSpread = 0; let removedSpread = 0;
let removedChar = 0; let removedChar = 0;
let gouged = 0; let gouged = 0;

View File

@ -8,9 +8,31 @@
* better technique, it's warmer toast: don't dawdle in the drawer. * better technique, it's warmer toast: don't dawdle in the drawer.
*/ */
export type SpreadId = 'butter' | 'peanut' | 'mitey'; export type SpreadId = 'butter' | 'peanut' | 'mitey' | 'marmalade';
export type AmountClass = 'thin' | 'normal' | 'thick'; export type AmountClass = 'thin' | 'normal' | 'thick';
/**
* Solid bits suspended in a spread marmalade rind. They ride the knife with
* the jelly, drop off as the blade travels, and the judge scores how evenly
* they ended up scattered. Dabbing makes a pile; a moving knife lays a trail;
* covering the whole slice takes several dips placed with intent.
*/
export interface ParticleDef {
/** What the judge calls them. */
name: string;
color: [number, number, number];
/** World-space size of a bit. */
size: number;
/** How many the knife picks up per dip. */
perDip: number;
/**
* Fraction of the carried bits shed per WORLD-UNIT of stroke, at a full load.
* Distance-based on purpose: a moving knife lays an even trail, a dabbing one
* makes a pile.
*/
shedRate: number;
}
export interface SpreadDef { export interface SpreadDef {
id: SpreadId; id: SpreadId;
name: string; name: string;
@ -21,6 +43,8 @@ export interface SpreadDef {
opaqueAt: number; opaqueAt: number;
/** Surface relief strength. */ /** Surface relief strength. */
bump: number; bump: number;
/** Suspended solids, if any. */
particles?: ParticleDef;
/** Pressure needed to make it flow, before any warming. */ /** Pressure needed to make it flow, before any warming. */
baseYield: number; baseYield: number;
@ -92,6 +116,34 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
scrapeEase: 1.25, scrapeEase: 1.25,
amounts: { thin: [0.03, 0.11], normal: [0.14, 0.28], thick: [0.32, 0.6] }, amounts: { thin: [0.03, 0.11], normal: [0.14, 0.28], thick: [0.32, 0.6] },
}, },
marmalade: {
id: 'marmalade',
name: 'Marmalade',
blurb: 'The jelly is easy. The rind goes where it wants — your job is to argue.',
color: [0.91, 0.45, 0.08],
gloss: 0.95,
// Jelly stays translucent even laid on thick — you always see toast through it.
opaqueAt: 0.85,
bump: 0.05,
particles: {
name: 'rind',
color: [0.62, 0.24, 0.04],
size: 0.055,
perDip: 9,
// ~0.12 world-units between bits at a full load — and wider as the blade
// empties, so one dip can't do the whole slice. Several dips, placed with
// intent, is the skill.
shedRate: 0.95,
},
baseYield: 0.2,
tempSoftening: 0.45,
viscosity: 0.55,
pickup: 0.62,
transferRate: 3.2,
drag: 0.35,
scrapeEase: 1.1,
amounts: { thin: [0.08, 0.18], normal: [0.22, 0.42], thick: [0.48, 0.8] },
},
}; };
/** Knife angle past which contact narrows enough to scrape instead of spread. */ /** Knife angle past which contact narrows enough to scrape instead of spread. */