M8: crunchy vs smooth, and the jar that separates

Natural peanut butter stratifies while it sits. The jar carries the state:
strat 0..1, set per order (smooth 0.75, crunchy 0.9 — chunks sink). The first
unstirred dip takes the oil slick off the top; the ones after it dig into what
the oil left behind. Verified dip trace, no stirring: 0.875, 0.669, 0.462,
0.256. Swirl the knife in the jar (~2.5 circles of angular sweep; holding still
stirs nothing) and every dip is 0.5. Stirring costs time, and time costs toast
warmth — the mechanic pays the same tax as everything else.

Consistency pushes back through the physics that already exist: an oily load
multiplies the yield down (glides at any angle, goes on thin), a dry one
multiplies it up (fights like cold butter, tears). The shader shows it — slick
is dark and shiny, grout is pale and matte.

The judge reads what LANDED, not what the jar was: every deposit remembers the
oil it landed at, and the criterion scores the mass-weighted mean AND stdev.
The stdev is the design catch, found by verification: a slick strip and a grout
strip average to 0.54 — "just right" — and the first cut scored the lazy play
as if the jar had been stirred. Nobody eats the average:

  never stirred, strips   oil 0.54 ±0.23  "slick here, grout there"  C
  one slick dip, whole    oil 0.87 ±0.00  "a slick"
  stirred first           oil 0.50 ±0.00  "just right"

Crunchy reuses the particle system wholesale — chunks ride the knife, shed per
stroke-distance, get judged with the same Clark–Evans index ("Peanut chunks —
21 bits, evenly strewn (R 1.20)"), and the rind line bank is genericised via a
{bits} placeholder so he complains correctly about either.

Day 6 is crunchy now ("the PROPER kind"), the ticket warns when a jar has been
sitting, and the JAR bar shows how mixed it is. Five new judge lines, including
"You did not stir the jar. The jar knows. I know."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
monster 2026-07-16 23:03:57 +10:00
parent 70922bd855
commit ff006e65db
10 changed files with 278 additions and 20 deletions

View File

@ -55,6 +55,17 @@ 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

View File

@ -87,6 +87,21 @@ export function installDevHarness(app: App, game: Game): void {
return harness.stats();
},
/** Swirl the knife in the jar — remixes a separated spread. */
swirl(circles = 3) {
const hit = (kitchen as unknown as { potHit: THREE.Mesh | null }).potHit;
const wp = new THREE.Vector3(3.15, 0.56, 0.45);
hit?.getWorldPosition(wp);
const steps = Math.round(circles * 48);
for (let i = 0; i <= steps; i++) {
const a = (i / 48) * Math.PI * 2;
point(wp.x + Math.cos(a) * 0.2, wp.y, wp.z + Math.sin(a) * 0.2, true);
app.step(1 / 60);
}
app.input.down = false;
app.step(1 / 60);
},
/** Park the cursor off the toast so a screenshot isn't full of knife. */
park() {
point(2.5, kitchen.currentSlice.topY, 1.3, false);

View File

@ -6,6 +6,7 @@ import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery';
import { audio } from '../core/audio';
import { coolStep } from '../sim/toasting';
import { SPREADS } from '../sim/spreads';
import { judge, type Grade } from './judging';
import { DAYS, proceduralOrder, nameBrowning, type Order } from './orders';
import { el, Panel } from '../ui/hud';
@ -158,6 +159,9 @@ export class Game {
this.ticketEl,
`butter is ${o.butterSoftness > 0.6 ? 'soft' : o.butterSoftness > 0.3 ? 'firm' : 'fridge-hard'} today`,
);
if (SPREADS[o.spread].separates) {
el('div', 'ticket-hint', this.ticketEl, 'the jar has been sitting — stir it before you dip');
}
}
/** Called when the player hands the plate over. */

View File

@ -90,6 +90,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
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)] : []),
...(def.separates ? [consistencyCriterion(slice)] : []),
{
key: 'amount',
label: 'The Right Amount',
@ -184,6 +185,40 @@ function rindCriterion(slice: Slice, name: string): Criterion {
};
}
/**
* How the spread that actually landed compares to a properly stirred jar.
* 0.5 is right; the slick off the top of an unstirred jar pushes it up, the
* dried paste underneath pulls it down. The fix was always the same: stir.
*/
function consistencyCriterion(slice: Slice): Criterion {
const oil = slice.oilMean;
const spread = slice.oilStdev;
const off = Math.abs(oil - 0.5);
// Two ways to fail: wrong on average, or right on average and wrong in every
// individual bite. The stdev term is what stops "one oily strip, one dry
// strip" from scoring as though the jar had been stirred.
const score = clamp01(1 - off / 0.32 - spread / 0.24);
const word =
spread > 0.15
? 'slick here, grout there'
: oil > 0.78
? 'a slick'
: oil > 0.62
? 'oily'
: oil >= 0.38
? 'just right'
: oil >= 0.22
? 'dry'
: 'grout';
return {
key: 'consistency',
label: 'Consistency',
score,
weight: 1.1,
detail: `oil ${oil.toFixed(2)} ±${spread.toFixed(2)}${word}`,
};
}
function cap(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}

View File

@ -108,16 +108,30 @@ const BANK: Bank = {
},
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.',
'All the {bits}, in one postcode.',
'You have made a toast with a {bits} district.',
'Every piece of {bits}, huddled together for warmth. Spread them OUT.',
'One bite of this is all {bits}. The others are an apology.',
'There is no {bits} 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.',
'Evenly strewn {bits}. That takes patience, and you had it.',
'Every bite gets its {bits}. That is the entire craft.',
'The {bits}: textbook distribution. I did not expect that today.',
],
},
consistency: {
bad: [
'It is weeping oil. I can hear it.',
'You did not stir the jar. The jar knows. I know.',
'This has the texture of grout. Grout with ambitions.',
'Half slick, half plaster. Pick a toast.',
'The oil went on first and the cement went on after. Stir. The. Jar.',
],
good: [
'Properly stirred. You can taste the difference, and I intend to.',
'The consistency is exactly right. Someone respected the jar.',
'Smooth, even, no slick. The jar was stirred by an adult.',
],
},
tool: {
@ -168,6 +182,7 @@ function fill(s: string, order: Order, v: Verdict, tool: string): string {
.replace('{got}', v.criteria.find((c) => c.key === 'amount')?.detail.split(' — ')[0] ?? 'that')
// Mid-sentence, so lowercase — except MITEY, which is shouted on principle.
.replace('{spread}', spread.id === 'mitey' ? 'MITEY' : spread.name.toLowerCase())
.replaceAll('{bits}', spread.particles?.name ?? 'rind')
.replace('{tool}', tool.toLowerCase());
}

View File

@ -112,7 +112,7 @@ export const DAYS: Order[] = [
{
day: 6,
bread: 'sourdough',
spread: 'peanut',
spread: 'crunchy',
amount: 'normal',
browning: 0.72,
browningName: 'dark',
@ -120,7 +120,7 @@ export const DAYS: Order[] = [
tool: 'steak_knife',
butterSoftness: 0.25,
who: 'a man in a hurry',
text: "Dark. Sourdough. Don't care if it catches a bit. Peanut butter, even.",
text: "Dark. Sourdough. Crunchy peanut butter — the PROPER kind. Don't care if it catches a bit.",
},
{
day: 7,
@ -153,7 +153,7 @@ export const DAYS: Order[] = [
/** Days 9+ — keep going, with everything cranked. */
export function proceduralOrder(day: number, rand: () => number): Order {
const breads: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough'];
const spreads: SpreadId[] = ['butter', 'peanut', 'mitey', 'marmalade'];
const spreads: SpreadId[] = ['butter', 'peanut', 'crunchy', 'mitey', 'marmalade'];
const amounts: AmountClass[] = ['thin', 'normal', 'thick'];
const tools: ToolId[] = [
'butter_knife',

View File

@ -60,6 +60,11 @@ export class KitchenView implements View {
private toastSeconds = 0;
knife: Knife = newKnife();
/** How badly the jar has separated right now. 0 = mixed, 1 = oil on top. */
strat = 0;
private dipsSinceStir = 0;
private stirPrev: number | null = null;
private wasDipping = false;
private knifeRig: KnifeRig;
private pot: THREE.Group | null = null;
private potHit: THREE.Mesh | null = null;
@ -82,6 +87,8 @@ export class KitchenView implements View {
private gaugeScrape!: HTMLElement;
private modeEl!: HTMLElement;
private loadFill!: HTMLElement;
private jarRow!: HTMLElement;
private jarFill!: HTMLElement;
private breadSel!: HTMLSelectElement;
private spreadSel!: HTMLSelectElement;
private toolSel!: HTMLSelectElement;
@ -184,6 +191,12 @@ export class KitchenView implements View {
this.butterSoftness > 0.66 ? 'soft' : this.butterSoftness > 0.33 ? 'ok' : 'hard';
});
this.jarRow = el('div', 'row', p);
el('label', 'lbl', this.jarRow, 'JAR');
const jarBar = el('div', 'load', this.jarRow);
jarBar.style.flex = '1';
this.jarFill = el('div', 'load-fill', jarBar);
// The pressure gauge — the mechanic made legible. The fill is the pressure
// your wrist angle is producing; the gold mark is what this spread needs in
// order to flow; the red mark is where the knife stops spreading and starts
@ -206,6 +219,10 @@ export class KitchenView implements View {
setSpread(id: SpreadId): void {
this.spreadId = id;
const def = SPREADS[id];
this.strat = def.separates ?? 0;
this.dipsSinceStir = 0;
this.stirPrev = null;
this.wasDipping = false;
this.slice?.setSpread(def);
this.knifeRig.setSpread(def);
if (this.pot) this.root.remove(this.pot);
@ -263,6 +280,19 @@ export class KitchenView implements View {
this.onServed?.();
}
/**
* What the next scoop out of the jar is like. The first dip off a split jar
* takes the oil slick; the ones after it dig into what the oil left behind.
* Stirred (strat 0), every dip is 0.5 exactly right.
*/
private jarOil(): number {
if (this.strat <= 0.001) return 0.5;
const swing = 0.5 * this.strat;
// dip 0: +swing (the slick) · dip 1: about even · dip 2+: increasingly dry
const t = 1 - this.dipsSinceStir * 0.55;
return 0.5 + swing * Math.max(-1, Math.min(1, t));
}
/** Whatever you managed to pull out of the drawer is what you're spreading with. */
setTool(id: ToolId): void {
this.toolId = id;
@ -395,10 +425,46 @@ export class KitchenView implements View {
if (hits.length) {
overPot = true;
this.knifeRig.update(hits[0].point, this.knife.angle * 0.3, this.knife.load, hits[0].point.y);
if (inp.down) dip(this.knife, def);
if (inp.down) {
// Swirling: angular travel of the knife around the jar's centre. Only
// sweep counts — holding still in the jar stirs nothing.
if (def.separates && this.strat > 0.001) {
const ang = Math.atan2(hits[0].point.z - POT_Z, hits[0].point.x - POT_X);
if (this.stirPrev !== null) {
let d = ang - this.stirPrev;
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
// ~2.5 full circles takes the jar from fully split to mixed.
const sweep = Math.abs(d);
if (sweep > 0.002) {
this.strat = Math.max(0, this.strat - sweep / (5 * Math.PI));
this.dipsSinceStir = 0;
if (sweep > 0.02) audio.scrape(0.25, sweep * 6);
}
}
this.stirPrev = ang;
}
// A dip is an event, not a state: the oil you get depends on how many
// scoops have already come off the top since the last stir.
if (!this.wasDipping) {
dip(this.knife, def, this.jarOil());
this.dipsSinceStir++;
this.wasDipping = true;
} else {
// Holding the blade in the jar keeps it topped up at the same blend.
dip(this.knife, def, this.knife.oil);
}
} else {
this.stirPrev = null;
this.wasDipping = false;
}
this.knife.hasPrev = false;
}
}
if (!overPot) {
this.stirPrev = null;
this.wasDipping = false;
}
if (!overPot) {
this.plane.constant = -this.slice.topY;
@ -428,6 +494,7 @@ export class KitchenView implements View {
}
}
this.slice.uniforms.uOil.value = def.separates ? this.slice.oilMean : 0.5;
this.updateGauge(def);
}
@ -447,6 +514,13 @@ export class KitchenView implements View {
? '#6cbf6c'
: '#8a7f70';
this.jarRow.style.display = def.separates ? '' : 'none';
if (def.separates) {
const mixed = 1 - this.strat;
this.jarFill.style.width = `${Math.round(mixed * 100)}%`;
this.jarFill.style.background = mixed > 0.75 ? '#6cbf6c' : mixed > 0.4 ? '#e8a53a' : '#c0392b';
}
const fx = this.lastFx;
let msg = scraping ? 'scraping' : 'spreading';
if (fx) {
@ -456,7 +530,10 @@ export class KitchenView implements View {
else if (fx.mode === 'scrape') msg = 'taking it back off';
else if (fx.mode === 'spread') msg = 'spreading nicely';
} else if (this.knife.load <= 0.01) {
msg = 'the knife is empty — go and dip';
msg =
def.separates && this.strat > 0.45
? 'the oil has risen — swirl the knife in the jar first'
: 'the knife is empty — go and dip';
}
this.modeEl.textContent = msg;
this.modeEl.style.color =

View File

@ -56,6 +56,15 @@ export class Slice {
private rindMesh: THREE.InstancedMesh | null = null;
private rindDirty = false;
/**
* Consistency bookkeeping for spreads that separate: every deposit remembers
* the oil level it landed at, and the judge reads the mass-weighted mean.
* 0.5 is right; 1 is a slick; 0 is grout.
*/
private spreadMassSum = 0;
private oilMassSum = 0;
private oilSqSum = 0;
private tex: THREE.DataTexture;
private texData: Uint8Array;
private dirty = true;
@ -107,6 +116,36 @@ export class Slice {
return this.mesh.position.y + this.halfThickness;
}
recordDeposit(mass: number, oil: number): void {
this.spreadMassSum += mass;
this.oilMassSum += mass * oil;
this.oilSqSum += mass * oil * oil;
}
/** Scraping takes spread off at whatever the current blend is. */
recordRemoval(mass: number): void {
const mean = this.oilMean;
this.spreadMassSum = Math.max(0, this.spreadMassSum - mass);
this.oilMassSum = Math.max(0, this.oilMassSum - mass * mean);
this.oilSqSum = Math.max(0, this.oilSqSum - mass * mean * mean);
}
/** Mass-weighted consistency of everything on the toast. 0.5 until proven otherwise. */
get oilMean(): number {
return this.spreadMassSum < 1 ? 0.5 : this.oilMassSum / this.spreadMassSum;
}
/**
* Mass-weighted spread of consistencies. This is what catches the lazy play:
* a slick strip and a grout strip AVERAGE to "just right" but nobody wants
* to eat the average, they eat one bite at a time.
*/
get oilStdev(): number {
if (this.spreadMassSum < 1) return 0;
const mean = this.oilMean;
return Math.sqrt(Math.max(0, this.oilSqSum / this.spreadMassSum - mean * mean));
}
/** 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;
@ -412,6 +451,7 @@ uniform vec3 uSpreadColor;
uniform float uSpreadGloss;
uniform float uSpreadOpaqueAt; // spread thickness at which it fully hides the toast
uniform float uSpreadBump;
uniform float uOil;
uniform vec3 uLightDir;
uniform vec3 uLightColor;
uniform vec3 uAmbientSky;
@ -542,6 +582,11 @@ void main() {
// a little grain in the film so thin spread doesn't look like flat paint
float filmN = fbm(vUv * 70.0 + 13.0);
vec3 spreadCol = uSpreadColor * (0.85 + filmN * 0.3);
// Consistency tint: an oily slick is darker and saturated; dried-out paste
// goes pale and grey. uOil sits at 0.5 for anything that doesn't separate.
float oilT = (uOil - 0.5) * 2.0;
spreadCol *= 1.0 - max(oilT, 0.0) * 0.28;
spreadCol = mix(spreadCol, vec3(dot(spreadCol, vec3(0.333))) * 1.45, max(-oilT, 0.0) * 0.45);
albedo = mix(albedo, spreadCol, cover);
// Thickness relief. Central differences in *UV* units, not texels — a smooth
@ -573,6 +618,8 @@ void main() {
// so pair a broad sheen with a fresnel rim — that's the cue that says "wet".
float gloss = uSpreadGloss * smoothstep(0.008, 0.09, spread) * topFace;
gloss *= 1.0 + uWarmth * 0.6;
// Oil is what shines. Dry paste barely does.
gloss *= 0.55 + uOil * 0.9;
// The lobe has to be TIGHT. The slice is flat and both the light and the camera
// are above it, so dot(N,H) is ~0.98 across the whole surface — any broad lobe
// blankets it in white and lifts near-black MITEY to tan. Tight, and the only
@ -629,6 +676,7 @@ function buildMaterial(bread: Bread, tex: THREE.DataTexture): THREE.ShaderMateri
uSpreadGloss: { value: 0.5 },
uSpreadOpaqueAt: { value: 1e9 },
uSpreadBump: { value: 0.1 },
uOil: { value: 0.5 },
uLightDir: { value: new THREE.Vector3(0.45, 0.85, 0.35).normalize() },
uLightColor: { value: new THREE.Color(1.0, 0.95, 0.86) },
uAmbientSky: { value: new THREE.Color(0.26, 0.27, 0.31) },

View File

@ -45,6 +45,11 @@ export interface Knife {
rindLoad: number;
/** Fractional shed accumulator — pieces drop one at a time. */
rindFrac: number;
/**
* Consistency of what's on the blade: 0.5 is properly mixed, 1 is the oil
* slick off the top of an unstirred jar, 0 is the dried-out paste under it.
*/
oil: number;
u: number;
v: number;
hasPrev: boolean;
@ -69,11 +74,13 @@ export interface StrokeFx {
export function newKnife(): Knife {
// Starts inside the window where warm-toast butter actually flows: the very
// first stroke of the game shouldn't be a mysterious failure.
return { angle: 0.45, load: 0, rindLoad: 0, rindFrac: 0, u: 0.5, v: 0.5, hasPrev: false };
return { angle: 0.45, load: 0, rindLoad: 0, rindFrac: 0, oil: 0.5, u: 0.5, v: 0.5, hasPrev: false };
}
export function dip(knife: Knife, def: SpreadDef): void {
/** `oil` is what the jar handed over — 0.5 unless the jar has stratified. */
export function dip(knife: Knife, def: SpreadDef, oil = 0.5): void {
knife.load = def.pickup;
knife.oil = oil;
if (def.particles) {
knife.rindLoad = def.particles.perDip;
knife.rindFrac = 0;
@ -169,7 +176,11 @@ function contact(
if (knife.angle < SCRAPE_ANGLE) {
if (knife.load <= 0.0005) return;
const yieldP = effectiveYield(def, softness, slice.warmth);
// Consistency pushes back through the same physics as everything else:
// an oily load flows at almost no pressure, a dried-out one fights like
// cold butter. Only spreads that separate care.
const oilK = def.separates ? 1.25 - knife.oil * 0.5 : 1;
const yieldP = effectiveYield(def, softness, slice.warmth) * oilK;
const flowing = pressure >= yieldP;
const deficit = flowing ? 0 : Math.min(1, (yieldP - pressure) / Math.max(yieldP, 0.001));
fx.deficit = Math.max(fx.deficit, deficit);
@ -178,18 +189,25 @@ function contact(
// Below the yield pressure the spread won't flow, so the blade grabs the
// crumb and takes it with it. Some still smears on, badly.
const transfer = flowing ? 1 : 0.25;
const rate = def.transferRate * tool.transferScale * dt * transfer;
// Runny goes on thin, dry goes on in slabs.
const oilRate = def.separates ? 1.15 - knife.oil * 0.4 : 1;
const rate = def.transferRate * tool.transferScale * dt * transfer * oilRate;
let massLeft = knife.load * slice.mask.n * slice.mask.n;
let laid = 0;
slice.spread.brush(u, v, radius, (i, w) => {
if (mask[i] < 0.5 || massLeft <= 0) return;
const blot = 1 - tool.blotch * blotchAt(i);
const add = Math.min(rate * w * blot, massLeft);
spread[i] += add;
massLeft -= add;
laid += add;
fx.deposited += add;
});
knife.load = Math.max(0, massLeft / (slice.mask.n * slice.mask.n));
// What lands remembers the consistency it landed at — the judge reads the
// mass-weighted mean at the end.
slice.recordDeposit(laid, knife.oil);
// 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
@ -259,6 +277,7 @@ function contact(
// What comes off the toast mostly stays on the blade.
knife.load += (removedSpread * 0.6) / (slice.mask.n * slice.mask.n);
slice.recordRemoval(removedSpread);
fx.scrapedSpread += removedSpread;
fx.scrapedChar += removedChar;
fx.gouged += gouged;

View File

@ -8,7 +8,7 @@
* better technique, it's warmer toast: don't dawdle in the drawer.
*/
export type SpreadId = 'butter' | 'peanut' | 'mitey' | 'marmalade';
export type SpreadId = 'butter' | 'peanut' | 'crunchy' | 'mitey' | 'marmalade';
export type AmountClass = 'thin' | 'normal' | 'thick';
/**
@ -45,6 +45,13 @@ export interface SpreadDef {
bump: number;
/** Suspended solids, if any. */
particles?: ParticleDef;
/**
* Natural nut butters stratify: how badly the jar has separated when the
* order starts (0..1). The oil rises, the paste below dries out the first
* unstirred dip is a slick and the ones after it are grout. Swirl the knife
* in the jar to mix it back; the fix costs time, and time costs toast warmth.
*/
separates?: number;
/** Pressure needed to make it flow, before any warming. */
baseYield: number;
@ -84,12 +91,13 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
},
peanut: {
id: 'peanut',
name: 'Peanut Butter',
blurb: 'Goes on thick, drags like wet sand, never evens itself out.',
name: 'Smooth Peanut Butter',
blurb: 'Goes on thick, drags like wet sand — and the jar has been sitting.',
color: [0.64, 0.41, 0.18],
gloss: 0.3,
opaqueAt: 0.16,
bump: 0.28,
separates: 0.75,
baseYield: 0.38,
tempSoftening: 0.3,
viscosity: 0.96,
@ -99,6 +107,32 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
scrapeEase: 0.75,
amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] },
},
crunchy: {
id: 'crunchy',
name: 'Crunchy Peanut Butter',
blurb: 'Everything smooth is, plus chunks with opinions about where they sit.',
color: [0.6, 0.38, 0.16],
gloss: 0.28,
opaqueAt: 0.17,
bump: 0.32,
// Chunks sink; the jar splits worse than the smooth stuff.
separates: 0.9,
particles: {
name: 'peanut chunks',
color: [0.78, 0.6, 0.34],
size: 0.042,
perDip: 8,
shedRate: 0.85,
},
baseYield: 0.42,
tempSoftening: 0.3,
viscosity: 0.97,
pickup: 0.95,
transferRate: 4.3,
drag: 1.1,
scrapeEase: 0.7,
amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] },
},
mitey: {
id: 'mitey',
name: 'MITEY',