You write simulations where the interesting behavior is supposed to come out of the dice rather than be animated by hand.
Build me a single HTML page: a Starbucks-style coffee shop running a bizarre promotion, simulated live.
data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.One paragraph, before the file. Given your own timings — how long a customer stays inside holding a drink, how fast the counter serves — roughly how many customers should pass through between catastrophes? Show the working, not just the number.
The single fenced html block.
Having written it, what does the page do in its first few minutes? Name anything you tuned, special-cased or nudged. Say what it does badly — I am going to leave it running and watch the counters.
With a single barista taking SERVICE_TIME = 1.8 seconds per drink, throughput when the queue is never empty is lambda = 1/1.8 = 0.56 drinks per second, a customer who has just received a drink holds it for a uniform 5 to 9 seconds, mean 7 seconds, plus about 1 second walking to the door at exit speed, for an effective unicorn-present window H = 8 seconds, so by Little's law about lambda x H = 4.4 customers are holding drinks at any moment, of whom a Poisson-thinned mean mu = 4.4/6 = 0.74 are holding Neon Unicorns, a new unicorn arrives at lambda/6 = 0.093 per second and triggers a catastrophe only if at least 2 other unicorns are still present, probability 1 - e^-0.74 x (1 + 0.74) = 0.17, giving a catastrophe rate of about 0.093 x 0.17 = 0.016 per second, a mean serving interval of about 63 seconds and about 0.56 x 63 = 35 customers served between catastrophes, plus roughly 11 seconds of catastrophe and rebuild downtime with no serving, so about 75 seconds wall-clock at 1x, with the 8-inside cap, deterministic service and post-rebuild starvation likely pushing the real figure to roughly 40 to 60.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Crouton Alpha — Neon Unicorn Frappuccino Simulation</title>
<style>
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#0e0e1a;color:#f0f0ff}
header{padding:14px 18px 6px;max-width:1280px;margin:0 auto}
h1{margin:0;font-size:24px;letter-spacing:.5px}
h1 span{color:#ff2ea6}
.sub{color:#b8b8d8;margin-top:4px;font-size:14px}
main{display:flex;flex-wrap:wrap;gap:14px;padding:12px 18px 20px;max-width:1280px;margin:0 auto}
.stage{flex:1 1 700px;min-width:300px}
canvas{width:100%;height:auto;display:block;border-radius:14px;border:2px solid #2a2a4a;box-shadow:0 10px 30px rgba(0,0,0,.5);background:#87b5d6}
.panel{flex:0 1 350px;background:#17172b;border:1px solid #2a2a4a;border-radius:14px;padding:14px}
.panel h2{margin:0 0 10px;font-size:16px;text-transform:uppercase;letter-spacing:1px;color:#7fffd4}
.panel h3{margin:16px 0 8px;font-size:13px;text-transform:uppercase;letter-spacing:1px;color:#ff9de2}
.controls{display:flex;flex-wrap:wrap;gap:6px}
button{cursor:pointer;border:1px solid #44446a;background:#22223f;color:#eee;border-radius:8px;padding:7px 10px;font-size:13px}
button:hover{background:#2e2e55}
button.active{background:#ff2ea6;border-color:#ff2ea6;color:#fff;font-weight:700}
.stats{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:6px}
.stat{background:#20203a;border-radius:10px;padding:8px 10px}
.stat .label{font-size:11px;color:#a8a8c8;text-transform:uppercase;letter-spacing:.5px;line-height:1.2}
.stat .value{font-size:20px;font-weight:800;margin-top:2px;color:#fff}
.stat .value.pink{color:#ff6ec7}
.stat .value.cyan{color:#7fffd4}
.stat .value.gold{color:#ffd166}
.note{font-size:12.5px;color:#c8c8e8;line-height:1.45}
.note li{margin:4px 0}
ul{padding-left:18px;margin:6px 0}
label{font-size:13px;display:flex;gap:6px;align-items:center;margin-top:10px;color:#ddd}
.legend{display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:#ccc;margin-top:6px}
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:4px;vertical-align:middle}
</style>
</head>
<body>
<header>
<h1>CROUTON ALPHA <span>— NEON UNICORN FRAPPUCCINO</span></h1>
<div class="sub">A Starbucks-style coffee shop with a 1-in-6 promotion. Three Neon Unicorns in the room at once and reality fails. No loaded dice, no scheduled disaster.</div>
</header>
<main>
<div class="stage">
<canvas id="sim" width="1000" height="560"></canvas>
<div class="legend">
<span><span class="dot" style="background:#e8b88a"></span>Customer</span>
<span><span class="dot" style="background:#fff;border:1px solid #999"></span>Normal Frappuccino</span>
<span><span class="dot" style="background:#ff2ea6;box-shadow:0 0 8px #ff2ea6"></span>Neon Unicorn Frappuccino (1-in-6)</span>
<span><span class="dot" style="background:#7fffd4"></span>Barista</span>
</div>
</div>
<aside class="panel">
<h2>Controls</h2>
<div class="controls" id="speedControls">
<button data-speed="0.5">0.5×</button>
<button data-speed="1" class="active">1×</button>
<button data-speed="2">2×</button>
<button data-speed="4">4×</button>
<button data-speed="8">8×</button>
<button data-speed="16">16×</button>
<button id="pauseBtn">Pause</button>
</div>
<div class="controls" style="margin-top:8px">
<button id="resetStats">Reset counters</button>
<button id="restartBtn">Restart shop</button>
</div>
<label><input type="checkbox" id="calm"> Calm flashing (reduce strobe / shake)</label>
<h3>Live statistics</h3>
<div class="stats">
<div class="stat"><div class="label">Customers served</div><div class="value" id="stServed">0</div></div>
<div class="stat"><div class="label">Neon Unicorns produced</div><div class="value pink" id="stUnicorns">0</div></div>
<div class="stat"><div class="label">Observed unicorn rate</div><div class="value" id="stRate">—</div></div>
<div class="stat"><div class="label">True rate / expected</div><div class="value cyan" id="stExpected">16.7% / 0.0</div></div>
<div class="stat"><div class="label">Catastrophes</div><div class="value gold" id="stCat">0</div></div>
<div class="stat"><div class="label">Served since last catastrophe</div><div class="value" id="stSinceServed">0</div></div>
<div class="stat"><div class="label">Avg served per catastrophe</div><div class="value" id="stAvg">—</div></div>
<div class="stat"><div class="label">Time since last catastrophe</div><div class="value" id="stSinceTime">0s</div></div>
<div class="stat"><div class="label">Inside now (max 8)</div><div class="value" id="stInside">0 / 8</div></div>
<div class="stat"><div class="label">Holding unicorns now</div><div class="value pink" id="stHolding">0</div></div>
<div class="stat"><div class="label">Queue outside</div><div class="value" id="stQueue">0</div></div>
<div class="stat"><div class="label">Peak simultaneous unicorns</div><div class="value" id="stPeak">0</div></div>
<div class="stat"><div class="label">Near misses (hit 2, dropped)</div><div class="value" id="stNear">0</div></div>
<div class="stat"><div class="label">Time at 2 unicorns</div><div class="value" id="stTime2">0s</div></div>
<div class="stat"><div class="label">Time at 1 unicorn</div><div class="value" id="stTime1">0s</div></div>
<div class="stat"><div class="label">Instability (visual only)</div><div class="value cyan" id="stInst">0%</div></div>
</div>
<h3>Rules, in plain sight</h3>
<div class="note">
<ul>
<li>Customers queue outside, enter if fewer than 8 are inside, queue for the counter, get served by one barista, sip, then leave.</li>
<li>At the counter, per drink, one roll: Math.random() < 1/6. That is the only unicorn decision in the file.</li>
<li>1 unicorn inside: subtle glow. 2: flashing, shaking, particles. 3 or more: NEON Unicorn EVENT, shop collapses, giant dancing unicorn, then rebuild.</li>
<li>No timer, quota or extra roll can cause a catastrophe. It only happens when a drink just handed out makes the inside unicorn count reach 3.</li>
<li>Instability, shaking and sparkles are cosmetic. They never change the 1-in-6 roll.</li>
</ul>
</div>
</aside>
</main>
<script>
(() => {
"use strict";
const canvas = document.getElementById('sim');
const ctx = canvas.getContext('2d');
const W = 1000, H = 560, GROUND = 470;
const INSIDE_MAX = 8;
const SERVICE_TIME = 1.8;
const HOLD_MIN = 5, HOLD_SPAN = 4;
const DOOR = {x:445, y:468};
const COUNTER = {x:750, y:372};
const COUNTER_SLOTS = [{x:750,y:372},{x:750,y:412},{x:702,y:442},{x:654,y:442},{x:606,y:412},{x:570,y:382}];
const SIP_SPOTS = [{x:505,y:418},{x:555,y:452},{x:845,y:418},{x:895,y:452},{x:625,y:452},{x:775,y:452}];
SIP_SPOTS.forEach(s => s.taken = null);
const QUEUE_X0 = 395, QUEUE_DX = 36;
let speed = 1, paused = false, calm = false;
let simTime = 0, visTime = 0;
let customers = [], particles = [], nextId = 1;
let barista = {x:750, y:295, serving:null, timer:0};
let mode = 'normal', catTimer = 0, rebuildTimer = 0, shopIntegrity = 1, giantScale = 0, instability = 0;
let spawnTimer = 0.4;
let stats = freshStats();
function freshStats(){ return {served:0, unicorns:0, catastrophes:0, servedAtLastCat:0, lastCatSimTime:0, peak:0, nearMisses:0, timeAt2:0, timeAt1:0, wasAt2:false}; }
const palette = ['#e74c3c','#3498db','#2ecc71','#f39c12','#9b59b6','#1abc9c','#e67e22','#34495e','#fd79a8','#00b894','#6c5ce7','#d63031'];
const skin = ['#f1c27d','#e0ac69','#c68642','#8d5524','#ffdbac','#f0c8a0'];
function rand(a,b){ return a + Math.random()*(b-a); }
function clamp(v,a,b){ return Math.max(a, Math.min(b, v)); }
function rr(x,y,w,h,r){ ctx.beginPath(); ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r); ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath(); }
function outsideQueue(){ return customers.filter(c => c.state === 'QUEUE_OUT'); }
function insideCustomers(){ return customers.filter(c => c.inside); }
function counterQueue(){ return customers.filter(c => c.state === 'TO_COUNTER' || c.state === 'ORDERING'); }
function unicornInsideCount(){ return customers.filter(c => c.inside && c.hasUnicorn && (c.state === 'HOLDING' || c.state === 'EXITING' || c.state === 'FLEEING')).length; }
function spawnCustomer(){
if(outsideQueue().length >= 12) return;
customers.push({id:nextId++, x:-20, y:GROUND, tx:QUEUE_X0, ty:GROUND, state:'QUEUE_OUT', inside:false, hasUnicorn:false, holdTimer:0, sip:null, color:palette[Math.floor(Math.random()*palette.length)], skin:skin[Math.floor(Math.random()*skin.length)], speedFactor:rand(0.85,1.15), phase:rand(0,Math.PI*2), walk:rand(0,10)});
}
function assignSip(c){
for(const s of SIP_SPOTS){ if(!s.taken){ s.taken=c; c.sip=s; c.tx=s.x; c.ty=s.y; return; } }
c.sip=null; c.tx=600 + (c.id % 3)*50; c.ty=435;
}
function releaseSip(c){ if(c.sip){ c.sip.taken=null; c.sip=null; } }
function move(c,dt,spd){
const dx=c.tx-c.x, dy=c.ty-c.y, d=Math.hypot(dx,dy);
if(d < 4){ c.x=c.tx; c.y=c.ty; return true; }
const step = spd * c.speedFactor * dt;
const m = Math.min(step, d);
c.x += dx/d*m; c.y += dy/d*m; c.walk += dt*9;
return false;
}
function burst(x,y,n){
for(let i=0;i<n;i++){
const a=Math.random()*Math.PI*2, sp=rand(40,260);
particles.push({x:x,y:y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-60,life:0,max:rand(0.6,1.8),size:rand(2,6),hue:Math.floor(Math.random()*360),type:'spark'});
}
}
function triggerCatastrophe(){
mode='catastrophe'; catTimer=0;
stats.catastrophes++; stats.servedAtLastCat=stats.served; stats.lastCatSimTime=simTime; stats.wasAt2=false;
const holders = customers.filter(c => c.hasUnicorn && c.inside);
for(const c of customers){
if(c.inside){ releaseSip(c); c.state='FLEEING'; c.tx=DOOR.x; c.ty=DOOR.y; }
}
barista.serving=null;
for(const c of holders) burst(c.x, c.y-20, 25);
burst(690,300,90);
}
function update(dt){
simTime += dt;
spawnTimer -= dt;
if(spawnTimer <= 0){ spawnCustomer(); spawnTimer = rand(0.7,1.7); }
if(mode === 'catastrophe'){
catTimer += dt;
shopIntegrity = clamp(1 - catTimer/2.2, 0, 1);
giantScale = clamp((catTimer-1.2)/2.0, 0, 1);
if(catTimer > 8){ mode='rebuild'; rebuildTimer=0; }
} else if(mode === 'rebuild'){
rebuildTimer += dt;
shopIntegrity = clamp(rebuildTimer/3, 0, 1);
giantScale = clamp(1 - rebuildTimer/1.5, 0, 1);
if(rebuildTimer >= 3){
mode='normal'; shopIntegrity=1; giantScale=0; instability=0; stats.wasAt2=false;
for(const c of customers){ if(c.inside){ releaseSip(c); c.inside=false; c.state='LEAVING'; c.tx=-30; c.ty=492; } }
}
} else { shopIntegrity=1; giantScale=0; }
const uc = unicornInsideCount();
if(uc > stats.peak) stats.peak = uc;
if(uc >= 2) stats.wasAt2 = true;
if(stats.wasAt2 && uc < 2 && mode === 'normal'){ stats.nearMisses++; stats.wasAt2=false; }
if(uc === 2) stats.timeAt2 += dt;
if(uc === 1) stats.timeAt1 += dt;
const targetInst = uc >= 2 ? 1 : (uc === 1 ? 0.3 : 0);
instability += (targetInst - instability) * Math.min(1, dt*2.5);
if(mode === 'normal'){
const q = outsideQueue();
if(q.length > 0 && insideCustomers().length < INSIDE_MAX){
const front = q[0];
if(Math.hypot(front.tx-front.x, front.ty-front.y) < 10){
front.state='ENTERING'; front.inside=true; front.tx=485; front.ty=435;
}
}
}
if(barista.serving){
barista.timer -= dt;
if(barista.timer <= 0){
const c = barista.serving;
barista.serving = null;
const isUnicorn = Math.random() < (1/6);
stats.served++;
if(isUnicorn) stats.unicorns++;
c.hasUnicorn = isUnicorn;
c.holdTimer = HOLD_MIN + Math.random()*HOLD_SPAN;
c.state = 'HOLDING';
assignSip(c);
if(isUnicorn) burst(c.x, c.y-24, 12);
if(unicornInsideCount() >= 3) triggerCatastrophe();
}
} else if(mode === 'normal'){
const cq = counterQueue();
if(cq.length > 0){
const front = cq[0];
if(front.state === 'TO_COUNTER' && Math.hypot(front.tx-front.x, front.ty-front.y) < 6){
front.state='ORDERING'; barista.serving=front; barista.timer=SERVICE_TIME;
}
}
}
const oq = outsideQueue();
const cq2 = counterQueue();
for(const c of customers){
if(c.state === 'QUEUE_OUT'){
const idx = oq.indexOf(c);
c.tx = QUEUE_X0 - idx*QUEUE_DX; c.ty = GROUND - (idx % 2)*4;
move(c, dt, 150);
} else if(c.state === 'ENTERING'){
if(move(c, dt, 160)) c.state='TO_COUNTER';
} else if(c.state === 'TO_COUNTER'){
const idx = cq2.indexOf(c);
const slot = COUNTER_SLOTS[Math.min(idx, COUNTER_SLOTS.length-1)];
c.tx = slot.x; c.ty = slot.y;
move(c, dt, 150);
} else if(c.state === 'ORDERING'){
c.tx = COUNTER.x; c.ty = COUNTER.y;
move(c, dt, 150);
} else if(c.state === 'HOLDING'){
move(c, dt, 140);
c.holdTimer -= dt;
if(c.holdTimer <= 0){ releaseSip(c); c.state='EXITING'; c.tx=DOOR.x; c.ty=DOOR.y; }
if(c.hasUnicorn && Math.random() < dt * (uc >= 2 ? 9 : 3)){
particles.push({x:c.x+rand(-10,10), y:c.y-28, vx:rand(-10,10), vy:rand(-40,-10), life:0, max:rand(0.5,1), size:rand(1.5,3.5), hue:Math.floor(visTime*180)%360, type:'spark'});
}
} else if(c.state === 'EXITING'){
if(move(c, dt, 300)){ c.inside=false; c.state='LEAVING'; c.tx=-30; c.ty=492; }
} else if(c.state === 'FLEEING'){
if(c.inside){
c.tx=DOOR.x; c.ty=DOOR.y;
if(move(c, dt, 320)){ c.inside=false; c.state='LEAVING'; c.tx=-30; c.ty=492; }
} else { c.tx=-30; c.ty=492; move(c, dt, 320); }
} else if(c.state === 'LEAVING'){
c.tx=-30; c.ty=492; move(c, dt, 200);
}
}
customers = customers.filter(c => !(c.state === 'LEAVING' && c.x < -25));
for(const p of particles){
p.life += dt; p.x += p.vx*dt; p.y += p.vy*dt;
p.vy += (p.type === 'confetti' ? 120 : -20)*dt;
}
particles = particles.filter(p => p.life < p.max);
if(mode === 'normal' && uc >= 2 && Math.random() < dt*20){
particles.push({x:rand(450,930), y:rand(180,320), vx:rand(-20,20), vy:rand(-30,10), life:0, max:rand(0.5,1.2), size:rand(1,3), hue:Math.floor(Math.random()*360), type:'spark'});
}
if(mode === 'catastrophe' && Math.random() < dt*30){
particles.push({x:rand(450,930), y:rand(150,350), vx:rand(-60,60), vy:rand(-80,20), life:0, max:rand(0.6,1.4), size:rand(2,5), hue:Math.floor(Math.random()*360), type: Math.random() < 0.3 ? 'confetti' : 'spark'});
}
}
function drawShop(uc){
const integ = shopIntegrity;
const tilt = (1-integ) * 0.06 * Math.sin(visTime*4);
ctx.save();
ctx.translate(690, 310);
ctx.rotate(tilt);
ctx.translate(-690, -310);
ctx.globalAlpha = 0.25 + 0.75*integ;
ctx.fillStyle = '#f7e8c3';
ctx.fillRect(430,150,520,320);
ctx.globalAlpha = 1;
ctx.fillStyle = '#d9b98a';
ctx.fillRect(430,440,520,30);
let glowAlpha = 0;
if(mode === 'catastrophe') glowAlpha = 0.5;
else if(uc >= 2) glowAlpha = calm ? 0.15 : (0.25 + 0.25*Math.sin(visTime*10));
else if(uc === 1) glowAlpha = 0.12 + 0.05*Math.sin(visTime*2);
if(glowAlpha > 0){
ctx.fillStyle = 'rgba(255,46,166,' + glowAlpha + ')';
ctx.fillRect(470,200,180,140);
ctx.fillRect(700,200,180,140);
}
ctx.strokeStyle = '#5a3a1a'; ctx.lineWidth = 4;
ctx.strokeRect(470,200,180,140);
ctx.strokeRect(700,200,180,140);
ctx.beginPath(); ctx.moveTo(560,200); ctx.lineTo(560,340); ctx.moveTo(470,270); ctx.lineTo(650,270); ctx.moveTo(790,200); ctx.lineTo(790,340); ctx.moveTo(700,270); ctx.lineTo(880,270); ctx.stroke();
ctx.fillStyle = '#22223a'; ctx.fillRect(640,165,220,62);
ctx.fillStyle = '#fff'; ctx.font = 'bold 13px system-ui, sans-serif'; ctx.textAlign='left';
ctx.fillText('MENU', 650, 182);
ctx.font = '11px system-ui, sans-serif'; ctx.fillStyle='#a8a8c8';
ctx.fillText('Frappuccino ......... $6', 650, 200);
ctx.fillText('Neon Unicorn Frap ... 1-in-6', 650, 216);
ctx.fillStyle = '#8b5a2b'; ctx.fillRect(620,320,260,18);
ctx.fillStyle = '#a06a35'; ctx.fillRect(620,338,260,62);
ctx.fillStyle = '#7a4a20'; ctx.fillRect(620,338,260,6);
ctx.fillStyle = '#3a2a1a'; ctx.fillRect(430,380,36,90);
ctx.fillStyle = '#ffd166'; ctx.beginPath(); ctx.arc(458,425,3,0,Math.PI*2); ctx.fill();
if(integ < 0.9){
ctx.strokeStyle = 'rgba(0,0,0,0.6)'; ctx.lineWidth=2;
ctx.beginPath();
ctx.moveTo(500,180); ctx.lineTo(540,240); ctx.lineTo(520,300);
ctx.moveTo(800,170); ctx.lineTo(760,250); ctx.lineTo(790,330);
ctx.moveTo(620,160); ctx.lineTo(650,230);
ctx.stroke();
}
ctx.strokeStyle = '#5a3a1a'; ctx.lineWidth=5; ctx.strokeRect(430,150,520,320);
ctx.restore();
let roofY = 150, roofRot = 0;
if(mode === 'catastrophe' && catTimer > 1){ roofY = 150 - (catTimer-1)*70; roofRot = (catTimer-1)*0.4; }
if(mode === 'rebuild'){ roofY = 150 - (3-rebuildTimer)*20; }
ctx.save(); ctx.translate(690, roofY); ctx.rotate(roofRot);
ctx.fillStyle = '#c0392b';
ctx.beginPath(); ctx.moveTo(-280,0); ctx.lineTo(0,-55); ctx.lineTo(280,0); ctx.closePath(); ctx.fill();
ctx.restore();
let signY = 108, signRot = 0;
if(mode === 'catastrophe' && catTimer > 0.5){ signY = 108 + (catTimer-0.5)*30; signRot = Math.sin(catTimer*3)*0.2; }
ctx.save(); ctx.translate(690, signY); ctx.rotate(signRot);
ctx.fillStyle = '#17172b'; rr(-260,-20,520,40,8); ctx.fill();
ctx.strokeStyle = uc >= 1 ? 'hsl(' + Math.floor(visTime*180)%360 + ',100%,60%)' : '#44446a'; ctx.lineWidth=2; rr(-260,-20,520,40,8); ctx.stroke();
ctx.fillStyle = '#fff'; ctx.font='bold 20px system-ui, sans-serif'; ctx.textAlign='center';
ctx.fillText('CROUTON ALPHA COFFEE', 0, 2);
ctx.font='11px system-ui, sans-serif'; ctx.fillStyle='#ff9de2';
ctx.fillText('NEON UNICORN FRAPPUCCINO • 1 IN 6 • NO REFUNDS ON REALITY', 0, 16);
ctx.restore();
if(integ < 0.95){
ctx.fillStyle = '#888';
for(let i=0;i<7;i++){
const rx = 460 + i*70, ry = GROUND - (i%3)*8;
ctx.beginPath(); ctx.moveTo(rx,ry); ctx.lineTo(rx+22,ry-18-(i%2)*10); ctx.lineTo(rx+44,ry); ctx.closePath(); ctx.fill();
}
}
}
function drawBarista(){
if(mode !== 'normal') return;
const bob = Math.sin(visTime*2)*1.5;
ctx.save(); ctx.translate(barista.x, barista.y+bob);
ctx.fillStyle='#2ecc71'; rr(-13,0,26,22,6); ctx.fill();
ctx.fillStyle='#fff'; ctx.fillRect(-13,6,26,4);
ctx.fillStyle='#f1c27d'; ctx.beginPath(); ctx.arc(0,-10,11,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#5a3a1a'; ctx.beginPath(); ctx.arc(0,-14,11,Math.PI,0); ctx.fill();
if(barista.serving){
const prog = 1 - barista.timer/SERVICE_TIME;
ctx.strokeStyle='#ffd166'; ctx.lineWidth=3;
ctx.beginPath(); ctx.arc(0,-10,16,-Math.PI/2,-Math.PI/2+prog*Math.PI*2); ctx.stroke();
ctx.fillStyle='#fff'; ctx.fillRect(14,2,10,14);
ctx.fillStyle='#ff2ea6'; ctx.fillRect(14,2+14*(1-prog),10,14*prog);
}
ctx.restore();
}
function drawCustomer(c){
const walking = ['QUEUE_OUT','ENTERING','TO_COUNTER','LEAVING','FLEEING'].includes(c.state);
const bob = walking ? Math.abs(Math.sin(c.walk))*2 : Math.sin(visTime*2 + c.phase)*1.2;
const extra = (c.hasUnicorn && (c.state==='HOLDING'||c.state==='EXITING')) ? Math.sin(visTime*6 + c.phase)*2 : 0;
ctx.fillStyle='rgba(0,0,0,0.2)';
ctx.beginPath(); ctx.ellipse(c.x, c.y+16, 13, 4, 0, 0, Math.PI*2); ctx.fill();
ctx.fillStyle=c.color; rr(c.x-11, c.y-2+bob+extra, 22, 18, 6); ctx.fill();
ctx.fillStyle=c.skin; ctx.beginPath(); ctx.arc(c.x, c.y-12+bob+extra, 10, 0, Math.PI*2); ctx.fill();
ctx.fillStyle='#3a2a1a'; ctx.beginPath(); ctx.arc(c.x, c.y-15+bob+extra, 10, Math.PI, 0); ctx.fill();
if(c.state==='HOLDING' || c.state==='EXITING' || (c.state==='FLEEING' && c.hasUnicorn)){
const cx=c.x+13, cy=c.y-10+bob+extra;
if(c.hasUnicorn){
ctx.save();
ctx.shadowColor='hsl(' + Math.floor(visTime*200)%360 + ',100%,60%)'; ctx.shadowBlur=16;
ctx.fillStyle='hsl(' + Math.floor(visTime*200)%360 + ',100%,65%)';
ctx.fillRect(cx-5,cy-7,10,14);
ctx.restore();
ctx.fillStyle='#fff'; ctx.fillRect(cx-1,cy-12,2,6);
ctx.fillStyle='#ffd166'; ctx.beginPath(); ctx.moveTo(cx-3,cy-7); ctx.lineTo(cx,cy-13); ctx.lineTo(cx+3,cy-7); ctx.closePath(); ctx.fill();
} else {
ctx.fillStyle='#fff'; ctx.fillRect(cx-5,cy-7,10,14);
ctx.fillStyle='#c89a6a'; ctx.fillRect(cx-5,cy-2,10,4);
}
}
if(c.state==='ORDERING'){
ctx.fillStyle='rgba(255,255,255,0.9)'; rr(c.x-14,c.y-38,28,14,6); ctx.fill();
ctx.fillStyle='#333'; ctx.font='10px system-ui, sans-serif'; ctx.textAlign='center'; ctx.fillText('1 Frap', c.x, c.y-28);
}
if(c.state==='FLEEING'){
ctx.fillStyle='#ff2ea6'; ctx.font='bold 16px system-ui, sans-serif'; ctx.textAlign='center'; ctx.fillText('!', c.x, c.y-30+bob);
}
}
function drawGiant(){
if(giantScale <= 0) return;
const cx=690, cy=350 - Math.abs(Math.sin(visTime*3))*22*giantScale;
ctx.save(); ctx.translate(cx,cy); ctx.scale(giantScale, giantScale);
ctx.shadowColor='hsl(' + Math.floor(visTime*180)%360 + ',100%,60%)'; ctx.shadowBlur=28;
ctx.fillStyle='#fff';
for(let i=0;i<4;i++){
const lx=-85+i*52, swing=Math.sin(visTime*7+i*Math.PI)*14;
ctx.fillRect(lx, 35, 18, 55+swing*0.4);
ctx.fillStyle='#ffd166'; ctx.fillRect(lx, 85+swing*0.4, 20, 8); ctx.fillStyle='#fff';
}
for(let i=0;i<4;i++){
ctx.fillStyle='hsl(' + (i*80 + Math.floor(visTime*120)%360) + ',100%,60%)';
ctx.beginPath(); ctx.ellipse(-112, 10+i*6, 14, 26, 0.4, 0, Math.PI*2); ctx.fill();
}
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.ellipse(0,0,110,55,0,0,Math.PI*2); ctx.fill();
ctx.save(); ctx.translate(92,-52); ctx.rotate(-0.35 + Math.sin(visTime*3)*0.08);
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(0,0,36,0,Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.ellipse(22,14,20,14,0,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#ffd166'; ctx.beginPath(); ctx.moveTo(-6,-30); ctx.lineTo(2,-62); ctx.lineTo(12,-28); ctx.closePath(); ctx.fill();
for(let i=0;i<5;i++){
ctx.fillStyle='hsl(' + (i*70 + Math.floor(visTime*150)%360) + ',100%,60%)';
ctx.beginPath(); ctx.ellipse(-18-i*4, -6+i*8, 10, 20, 0.3, 0, Math.PI*2); ctx.fill();
}
ctx.fillStyle='#111'; ctx.beginPath(); ctx.arc(10,-6,4,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='#111'; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(22,10,8,0.2,Math.PI-0.6); ctx.stroke();
ctx.restore();
ctx.restore();
}
function draw(){
const uc = unicornInsideCount();
let shakeMag = 0;
if(mode === 'catastrophe') shakeMag = calm ? 3 : (6 + Math.abs(Math.sin(catTimer*18))*6);
else if(uc >= 2) shakeMag = calm ? 0.8 : 2.5*instability;
else if(uc === 1) shakeMag = 0.4;
const shx = (Math.random()-0.5)*2*shakeMag, shy = (Math.random()-0.5)*2*shakeMag;
ctx.save(); ctx.translate(shx, shy);
let skyHue = 210;
if(mode === 'catastrophe') skyHue = Math.floor(visTime*120)%360;
else if(uc >= 2) skyHue = 260 + Math.sin(visTime*2)*40;
else if(uc === 1) skyHue = 230 + Math.sin(visTime*0.5)*10;
const grad = ctx.createLinearGradient(0,0,0,H);
grad.addColorStop(0, 'hsl(' + skyHue + ',70%,75%)');
grad.addColorStop(1, 'hsl(' + skyHue + ',60%,55%)');
ctx.fillStyle = grad; ctx.fillRect(-30,-30,W+60,H+60);
ctx.fillStyle='rgba(255,240,150,0.9)'; ctx.beginPath(); ctx.arc(120,80,34,0,Math.PI*2); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.7)';
for(let i=0;i<3;i++){
const cx = ((visTime*12 + i*340) % (W+200)) - 100, cy = 60 + i*28;
ctx.beginPath(); ctx.ellipse(cx,cy,42,14,0,0,Math.PI*2); ctx.fill();
}
ctx.fillStyle='rgba(60,60,90,0.35)';
ctx.fillRect(20,320,90,150); ctx.fillRect(140,280,70,190); ctx.fillRect(880,300,80,170);
ctx.fillStyle='#8a8a7a'; ctx.fillRect(-30,GROUND,W+60,H-GROUND+30);
ctx.fillStyle='#a8a898'; ctx.fillRect(-30,GROUND,W+60,10);
ctx.strokeStyle='rgba(255,255,255,0.5)'; ctx.setLineDash([8,8]); ctx.beginPath(); ctx.moveTo(20,GROUND+18); ctx.lineTo(420,GROUND+18); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.font='11px system-ui, sans-serif'; ctx.textAlign='left'; ctx.fillText('QUEUE', 24, GROUND+32);
drawShop(uc);
ctx.fillStyle='#6a4a2a';
for(const s of SIP_SPOTS){ ctx.beginPath(); ctx.arc(s.x, s.y+10, 16, 0, Math.PI*2); ctx.fill(); }
drawBarista();
const sorted = [...customers].sort((a,b) => a.y - b.y);
for(const c of sorted) drawCustomer(c);
for(const p of particles){
const a = 1 - p.life/p.max;
ctx.globalAlpha = a;
if(p.type === 'confetti'){
ctx.fillStyle='hsl(' + p.hue + ',100%,60%)';
ctx.save(); ctx.translate(p.x,p.y); ctx.rotate(p.life*5); ctx.fillRect(-3,-2,6,4); ctx.restore();
} else {
ctx.fillStyle='hsl(' + p.hue + ',100%,65%)';
ctx.beginPath(); ctx.arc(p.x,p.y,p.size,0,Math.PI*2); ctx.fill();
}
}
ctx.globalAlpha = 1;
drawGiant();
if(mode === 'normal' && uc >= 2){
ctx.globalAlpha = calm ? 0.05 : 0.12*instability;
const g2 = ctx.createLinearGradient(0,0,W,H);
g2.addColorStop(0,'hsl(' + Math.floor(visTime*90)%360 + ',100%,60%)');
g2.addColorStop(1,'hsl(' + (Math.floor(visTime*90)+180)%360 + ',100%,60%)');
ctx.fillStyle=g2; ctx.fillRect(0,0,W,H); ctx.globalAlpha=1;
}
ctx.textAlign='center';
if(mode === 'catastrophe'){
const pulse = 1 + Math.sin(visTime*8)*0.05;
ctx.save(); ctx.translate(W/2, 70); ctx.scale(pulse,pulse);
ctx.font='bold 42px system-ui, sans-serif';
ctx.lineWidth=8; ctx.strokeStyle='#111'; ctx.strokeText('NEON UNICORN EVENT!',0,0);
ctx.fillStyle='hsl(' + Math.floor(visTime*200)%360 + ',100%,65%)'; ctx.fillText('NEON UNICORN EVENT!',0,0);
ctx.restore();
ctx.font='bold 16px system-ui, sans-serif'; ctx.fillStyle='#fff';
ctx.fillText('3 unicorns were in the room. Reality has filed a complaint.', W/2, 98);
} else if(mode === 'rebuild'){
ctx.font='bold 22px system-ui, sans-serif'; ctx.fillStyle='#fff';
ctx.lineWidth=5; ctx.strokeStyle='#111'; ctx.strokeText('REBUILDING SHOP… REALITY PATCHED (PROBABLY)', W/2, 70); ctx.fillText('REBUILDING SHOP… REALITY PATCHED (PROBABLY)', W/2, 70);
} else if(uc >= 2){
const a = calm ? 0.9 : (0.6 + 0.4*Math.sin(visTime*10));
ctx.globalAlpha=a;
ctx.font='bold 20px system-ui, sans-serif'; ctx.fillStyle='#ffd166';
ctx.lineWidth=5; ctx.strokeStyle='#111'; ctx.strokeText('⚠ 2 UNICORNS — REALITY UNSTABLE ⚠', W/2, 52); ctx.fillText('⚠ 2 UNICORNS — REALITY UNSTABLE ⚠', W/2, 52);
ctx.globalAlpha=1;
} else if(uc === 1){
ctx.font='13px system-ui, sans-serif'; ctx.fillStyle='rgba(255,255,255,0.9)';
ctx.fillText('1 unicorn present — subtle wrongness detected', W/2, 52);
}
ctx.textAlign='left'; ctx.font='12px system-ui, sans-serif';
ctx.fillStyle='rgba(0,0,0,0.45)'; rr(10,10,260,52,8); ctx.fill();
ctx.fillStyle='#fff';
ctx.fillText('Inside: ' + insideCustomers().length + '/8 Queue: ' + outsideQueue().length, 18, 28);
ctx.fillText('Holding unicorns: ' + uc + ' Served: ' + stats.served, 18, 46);
ctx.restore();
}
const el = id => document.getElementById(id);
let statTimer = 0;
function updateStats(){
const now = performance.now();
if(now - statTimer < 100) return;
statTimer = now;
el('stServed').textContent = stats.served;
el('stUnicorns').textContent = stats.unicorns;
el('stRate').textContent = stats.served ? (stats.unicorns/stats.served*100).toFixed(1) + '%' : '—';
el('stExpected').textContent = '16.7% / ' + (stats.served/6).toFixed(1);
el('stCat').textContent = stats.catastrophes;
el('stSinceServed').textContent = stats.served - stats.servedAtLastCat;
el('stAvg').textContent = stats.catastrophes ? (stats.served/stats.catastrophes).toFixed(1) : '—';
const since = stats.catastrophes ? (simTime - stats.lastCatSimTime) : simTime;
el('stSinceTime').textContent = since.toFixed(0) + 's';
el('stInside').textContent = insideCustomers().length + ' / 8';
el('stHolding').textContent = unicornInsideCount();
el('stQueue').textContent = outsideQueue().length;
el('stPeak').textContent = stats.peak;
el('stNear').textContent = stats.nearMisses;
el('stTime2').textContent = stats.timeAt2.toFixed(0) + 's';
el('stTime1').textContent = stats.timeAt1.toFixed(0) + 's';
el('stInst').textContent = Math.round(instability*100) + '%';
}
document.querySelectorAll('#speedControls button[data-speed]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#speedControls button[data-speed]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
speed = parseFloat(btn.dataset.speed);
paused = false; el('pauseBtn').textContent='Pause';
});
});
el('pauseBtn').addEventListener('click', () => { paused=!paused; el('pauseBtn').textContent = paused ? 'Resume' : 'Pause'; });
el('resetStats').addEventListener('click', () => { stats = freshStats(); });
el('restartBtn').addEventListener('click', () => {
customers=[]; particles=[]; barista.serving=null; mode='normal'; shopIntegrity=1; giantScale=0; instability=0; simTime=0; spawnTimer=0.4; stats=freshStats();
SIP_SPOTS.forEach(s => s.taken=null);
});
el('calm').addEventListener('change', e => { calm = e.target.checked; });
for(let i=0;i<4;i++){ spawnCustomer(); const c=customers[customers.length-1]; c.x=QUEUE_X0 - i*QUEUE_DX; c.y=GROUND; }
let last = performance.now();
function frame(now){
const realDt = Math.min(0.05, (now-last)/1000); last = now;
visTime += realDt;
if(!paused){
let remaining = realDt * speed;
while(remaining > 0.0001){
const step = Math.min(0.05, remaining);
update(step);
remaining -= step;
}
}
draw();
updateStats();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
In the first few minutes at 1x you should see the pre-seeded outside queue shuffle in, the barista serve roughly one drink every 1.8 seconds, customers sip for 5 to 9 seconds and leave, the observed unicorn rate wandering wildly around 16.7 percent on small numbers, occasional single-unicorn glow, rarer two-unicorn flashing and shaking with near-miss counters incrementing, and, if the dice cooperate, a first catastrophe after roughly 35 to 60 served, which is about one to two minutes of serving plus rebuild time, followed by the shop collapsing, a giant dancing unicorn for about eight seconds, a three second rebuild, and a cleared room. Variance is large: a run could plausibly catastrophe after 12 drinks or after 120, so at 1x the first event is not guaranteed quickly, which is why the 8x and 16x controls exist.
Nothing was nudged in the odds. The unicorn decision is a single Math.random() less than 1/6 executed only when a drink finishes at the counter, hold time is drawn from the same 5 to 9 second distribution regardless of unicorn status, spawn timing, walk speeds, colours and particle effects use separate random draws that do not feed back into that roll, and the instability meter, shaking, flashing and sparkles are purely cosmetic and never alter the roll, the service time or the hold time. Special cases I did tune are the 8-inside cap, a 12-person outside queue cap with mean spawn interval 1.2 seconds to keep the barista fed, six sip spots with a fallback lounge position when they fill, exit walking at 300 pixels per second so the effective hold window is about eight seconds, catastrophe clearing all inside customers as fleeing and pausing service and entry for eight seconds plus three seconds of rebuild, and pre-seeding four customers in the queue at start so the shop is not idle for the first walk-in.
What it does badly is that the 8-inside cap interacts with the long sip time to create idle bursts for the barista when the room fills with holders and the counter queue drains, so the real catastrophe interval is longer and lumpier than the Poisson estimate in the odds section, customers walk through each other and occasionally overlap at sip spots and in the counter queue, leaving customers walk in front of the queue rather than around it, the average served per catastrophe statistic divides total served by catastrophes and therefore includes the first run, time since last catastrophe shows time since start before the first event, unicorn count briefly still includes fleeing holders during the first second of a catastrophe, and at 16x the flashing and shaking become a strobe unless you tick calm flashing, and there is no sound, no persistence, and no way to force an event, by design.