27:18Gemini 4 Hydrogen: Cheaper Tokens, More Expensive Answers?
Nov 13, 2026 · YouTube
18:40ERNIE 5.5 Flash Preview vs GLM 5.3 Flash: Baidu's new model is very impressive!
Nov 11, 2026 · YouTube
36:22Fable 5.2 robbing me blind, plus: Nex AGI's N2.6 vs GLM 5.3 Flash
Nov 11, 2026 · YouTube
14:03Gemini 4 Carbon: Can it handle my challenges? (First look with one-shot coding tests)
Nov 10, 2026 · YouTube
10:57"Crouton Alpha": the latest super-efficient coding model by Meta?
Nov 3, 2026 · YouTube
38:58Testing Particle-1: a post-trained DeepSeek V4.1 Flash specializing in coding and efficiency
Oct 28, 2026 · YouTube
22:14GPT-6.1 Terra Pro vs Terra: How Much Better Is Pro?
Oct 27, 2026 · YouTube
29:55Kimi K3.1 Pro Looked Incredible. Then I Kept Testing.
Oct 26, 2026 · YouTube
You build physics toys, and you have watched someone try the tablecloth trick at a family dinner.
Build me a single HTML page: a table set for dinner, and a tablecloth I can yank out from under it.
#speed=12 sets the pull speed to 12 ft/s and pulls one second after load. Any number works.data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.One paragraph, before the file: the lowest pull speed at which every object stays on the table, and the speed below which no object stays on the table, as two numbers in ft/s. State the arithmetic using your masses, friction coefficients, and cloth length.
The single fenced html block.
Where it behaves like the physical trick and where it does not. Name any special cases, scripted wobbles, or fall thresholds. Everyone has seen this trick fail; say how yours fails before I find out.
My table is 5 ft long with the cloth's far edge starting at the far end, so an object whose pull-side contact point sits a distance d from that far edge has cloth sliding beneath it for t = d/V, accelerating at a = μ·g (mass cancels in Coulomb friction — my 2.4 lb candlestick and 0.5 lb teacup obey identical sums; mass only re-enters through collisions). The object is captured — sticks to the cloth and rides off the near edge — if V < √(μ_c·g·d); otherwise it's released at u = μ_c·g·d/V and slides u²/(2μ_t·g) on the bare wood, going over the edge only if that exceeds its remaining runway (5 − x). With g = 32.2 ft/s², μ_c = 0.30–0.45, μ_t = 0.25–0.35, and d = 0.95–4.23 ft for my seven objects: the last thing captured is the far dinner plate (d = 0.95 ft, μ_c = 0.35), √(0.35 × 32.2 × 0.95) ≈ 3.3 ft/s — below ≈ 3.3 ft/s every object eventually matches the cloth's speed and gets carried off, so nothing stays. The hardest object to keep is the near goblet (d = 4.23 ft, μ_c = 0.30, μ_t = 0.28, only 0.88 ft of runway): it needs V > √(0.30 × 32.2 × 4.23) ≈ 6.4 ft/s to avoid capture, and V > 0.30 × 4.23 × √(32.2 / (2 × 0.28 × 0.88)) ≈ 10.3 ft/s so its stop distance stays inside its margin — above ≈ 10.3 ft/s everything stays on the table. (Staying upright is stricter: the tall candlestick, base half-width 0.15 ft with its COM 0.5 ft up, rocks over below roughly 11–12 ft/s even while staying aboard.)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Tablecloth Trick</title>
<style>
:root{
--paper:#fbf5e7; --ink:#3a2c1a; --line:#d9c8a4;
--accent:#b5543b; --accent2:#8a5f33;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:#241b12;
font-family:Georgia,'Iowan Old Style','Palatino Linotype','Times New Roman',serif}
#stage{position:fixed;inset:0;display:block}
.card{position:fixed;background:var(--paper);border:1px solid var(--line);border-radius:12px;
box-shadow:0 10px 30px rgba(20,12,4,.35), inset 0 0 0 1px rgba(255,255,255,.5);
color:var(--ink);padding:12px 16px;z-index:5;user-select:none}
#titleCard{top:16px;left:18px;max-width:400px}
#titleCard h1{margin:0 0 2px;font-size:22px;letter-spacing:.3px}
#titleCard .sub{margin:0 0 7px;font-size:13px;font-style:italic;color:#6b5636}
#titleCard .theory{margin:0;font-size:12px;color:#7c6642;border-top:1px dashed var(--line);padding-top:6px}
#controls{top:16px;right:18px;width:350px}
.ctl-row{display:flex;align-items:center;gap:10px;margin:6px 0}
.ctl-row label{font-size:13px;font-weight:700;white-space:nowrap}
input[type=range]{flex:1;accent-color:var(--accent);min-width:0}
input[type=number]{width:72px;padding:4px 6px;border:1px solid var(--line);border-radius:6px;
background:#fff;color:var(--ink);font:inherit;font-size:14px}
.unit{font-size:12px;color:#7c6642}
button{font:inherit;font-size:14px;padding:8px 14px;border-radius:8px;cursor:pointer;
border:1px solid var(--accent2);transition:transform .06s ease}
button:active{transform:translateY(1px)}
#yankBtn{background:var(--accent);color:#fdf6e9;border-color:#8f3f2b;box-shadow:0 3px 0 #8f3f2b;font-weight:700}
#yankBtn:disabled{background:#b9a98c;border-color:#9c8d70;box-shadow:0 3px 0 #9c8d70;cursor:default}
#resetBtn{background:#fff;color:var(--accent2)}
.ticks{position:relative;height:15px;margin:-2px 6px 4px}
.ticks i{position:absolute;top:0;width:1px;height:6px;background:#b6a179}
.ticks span{position:absolute;top:5px;font-size:10px;color:#9b8659;transform:translateX(-50%);white-space:nowrap}
#status{margin-top:6px;font-size:13px;min-height:18px;color:#5c492c}
#status.busy{color:var(--accent);font-weight:700}
.keys{margin-top:4px;font-size:11px;color:#9b8659}
#results{left:50%;bottom:22px;transform:translateX(-50%) translateY(14px);opacity:0;
pointer-events:none;width:540px;max-width:calc(100vw - 32px);
transition:opacity .45s ease, transform .45s cubic-bezier(.2,.9,.3,1.15)}
#results.show{opacity:1;transform:translateX(-50%) translateY(0);pointer-events:auto}
#results h2{margin:0 0 4px;font-size:18px}
#results .counts{font-size:15px;margin-bottom:8px}
#results .counts b{font-size:22px;color:var(--accent)}
#fates{list-style:none;margin:0 0 8px;padding:0;font-size:13px;columns:2;column-gap:20px}
#fates li{display:flex;align-items:center;gap:7px;padding:2px 0;break-inside:avoid}
#fates em{margin-left:auto;font-style:normal;color:#7c6642}
.dot{width:10px;height:10px;border-radius:50%;border:1px solid rgba(0,0,0,.25);flex:none}
#quip{margin:0;font-style:italic;color:#6b5636;border-top:1px dashed var(--line);padding-top:6px;font-size:13px}
@media (max-width:980px){#titleCard{max-width:290px}#controls{width:300px}#fates{columns:1}}
</style>
</head>
<body>
<canvas id="stage"></canvas>
<div class="card" id="titleCard">
<h1>The Tablecloth Trick</h1>
<p class="sub">A dinner service, one cloth, and your yank. Friction and inertia do the rest.</p>
<p class="theory">Theory, from μ, g and the 5 ft sweep: below ≈ 3.3 ft/s nothing stays;
above ≈ 10.3 ft/s everything stays; the candlestick keeps its feet only above ≈ 11 ft/s.</p>
</div>
<div class="card" id="controls">
<div class="ctl-row">
<label for="speedRange">Pull speed</label>
<input type="range" id="speedRange" min="0.5" max="30" step="0.1" value="12">
<input type="number" id="speedNum" min="0.5" max="30" step="0.1" value="12" aria-label="Pull speed in feet per second">
<span class="unit">ft/s</span>
</div>
<div class="ticks">
<i style="left:9.5%"></i><span style="left:13%">3.3 · nothing stays</span>
<i style="left:33.2%"></i><span style="left:38%">10.3 · all stay</span>
</div>
<div class="ctl-row">
<button id="yankBtn">Yank the cloth</button>
<button id="resetBtn">Reset table</button>
</div>
<div id="status">Ready — set a pull speed, then yank.</div>
<div class="keys">Y / Space — yank · R — reset · open with #speed=12 to auto-yank in 1 s</div>
</div>
<div class="card" id="results">
<h2>Everything has settled</h2>
<div class="counts" id="countsLine"></div>
<ul id="fates"></ul>
<p id="quip"></p>
</div>
<script>
"use strict";
/* ================= world constants (feet, seconds, pounds) ================= */
const G = 32.174;
const TBL = { x0:0, x1:5.0, h:2.5, th:0.14 }; // table top span, height, slab thickness
const CLOTH_TH = 0.035, HANG0 = 1.3; // cloth thickness, initial hanging length
const DT = 1/600, RAMP = 0.12; // physics step, hand ramp-up time
const DEFS = [
{name:'Dinner plate', kind:'plate', x:0.50, hw:0.45, hh:0.035, m:1.6, muC:0.35, muT:0.30, muF:0.30},
{name:'Water goblet', kind:'goblet', x:1.26, hw:0.11, hh:0.31, m:0.9, muC:0.30, muT:0.28, muF:0.28},
{name:'Fruit bowl', kind:'bowl', x:1.88, hw:0.30, hh:0.15, m:1.8, muC:0.40, muT:0.35, muF:0.35},
{name:'Dinner plate', kind:'plate', x:2.70, hw:0.45, hh:0.035, m:1.6, muC:0.35, muT:0.30, muF:0.30},
{name:'Candlestick', kind:'candle', x:3.36, hw:0.15, hh:0.475, m:2.4, muC:0.42, muT:0.25, muF:0.30, I:0.15},
{name:'Teacup', kind:'cup', x:3.86, hw:0.10, hh:0.12, m:0.5, muC:0.32, muT:0.30, muF:0.30},
{name:'Water goblet', kind:'goblet', x:4.12, hw:0.11, hh:0.31, m:0.9, muC:0.30, muT:0.28, muF:0.28},
];
const KINDCOLOR = {plate:'#e8e0cd', goblet:'#8fb8c9', bowl:'#d98e3a', candle:'#b98a2f', cup:'#c76b8f'};
const LEGS = [
{x:0.42, y:(TBL.h-TBL.th)/2, hw:0.065, hh:(TBL.h-TBL.th)/2, th:0, invM:0, invI:0, muT:0.4},
{x:4.58, y:(TBL.h-TBL.th)/2, hw:0.065, hh:(TBL.h-TBL.th)/2, th:0, invM:0, invI:0, muT:0.4},
];
/* ================= state ================= */
let bodies=[], particles=[], events=[];
let state='ready'; // ready | pulling | settled
let simT=0, wallT=0, pullV=12, clothFront=0, clothV=0, still=0;
let candleLit=true, clothOff=false, ribbon=null, lastSnd=0;
/* ================= DOM ================= */
const cv=document.getElementById('stage'), ctx=cv.getContext('2d');
const speedRange=document.getElementById('speedRange'), speedNum=document.getElementById('speedNum');
const yankBtn=document.getElementById('yankBtn'), resetBtn=document.getElementById('resetBtn');
const statusEl=document.getElementById('status'), resultsEl=document.getElementById('results');
const countsLine=document.getElementById('countsLine'), fatesEl=document.getElementById('fates');
const quipEl=document.getElementById('quip');
/* ================= helpers ================= */
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
function wrapA(a){ return ((a+Math.PI)%(2*Math.PI)+2*Math.PI)%(2*Math.PI)-Math.PI; }
function corners(b){
const c=Math.cos(b.th), s=Math.sin(b.th);
return [{x:b.x+c*b.hw-s*b.hh, y:b.y+s*b.hw+c*b.hh},
{x:b.x+c*b.hw+s*b.hh, y:b.y+s*b.hw-c*b.hh},
{x:b.x-c*b.hw-s*b.hh, y:b.y-s*b.hw+c*b.hh},
{x:b.x-c*b.hw+s*b.hh, y:b.y-s*b.hw-c*b.hh}];
}
/* ================= reset ================= */
function reset(){
bodies = DEFS.map(d=>{
const I = d.I || d.m*((2*d.hw)**2 + (2*d.hh)**2)/12;
return {name:d.name, kind:d.kind, hw:d.hw, hh:d.hh, m:d.m, I, invM:1/d.m, invI:1/I,
muC:d.muC, muT:d.muT, muF:d.muF,
x:d.x, x0:d.x, y:TBL.h+CLOTH_TH+d.hh, th:0, vx:0, vy:0, om:0,
tipped:false, spilled:false};
});
particles=[]; events=[];
state='ready'; simT=0; clothFront=0; clothV=0; still=0;
candleLit=true; clothOff=false; ribbon=null;
resultsEl.classList.remove('show');
yankBtn.disabled=false; yankBtn.textContent='Yank the cloth';
}
/* ================= contacts ================= */
function gather(){
const cs=[];
for(const a of bodies){
const cs4=corners(a);
for(const p of cs4){
if(p.y < 0) // floor
cs.push({a,b:null,px:p.x,py:p.y,nx:0,ny:1,depth:-p.y,mu:a.muF,e:0.2,svx:0,svy:0,surf:'floor'});
if(p.x>TBL.x0 && p.x<TBL.x1 && p.y<TBL.h+CLOTH_TH && p.y>TBL.h-0.3){ // table top / cloth
const onC = p.x >= clothFront;
const d = TBL.h + (onC?CLOTH_TH:0) - p.y;
if(d>0) cs.push({a,b:null,px:p.x,py:p.y,nx:0,ny:1,depth:d,
mu:onC?a.muC:a.muT, e:0.06, svx:onC?clothV:0, svy:0, surf:onC?'cloth':'table'});
}
if(p.y>TBL.h-TBL.th && p.y<TBL.h && p.x>TBL.x1 && p.x<TBL.x1+0.12) // near edge face
cs.push({a,b:null,px:p.x,py:p.y,nx:1,ny:0,depth:p.x-TBL.x1,mu:a.muT,e:0.1,svx:0,svy:0,surf:'table'});
if(p.y>TBL.h-TBL.th && p.y<TBL.h && p.x<TBL.x0 && p.x>TBL.x0-0.12) // far edge face
cs.push({a,b:null,px:p.x,py:p.y,nx:-1,ny:0,depth:TBL.x0-p.x,mu:a.muT,e:0.1,svx:0,svy:0,surf:'table'});
}
}
for(const a of bodies) for(const L of LEGS) cornerVsRect(a,L,cs);
for(let i=0;i<bodies.length;i++) for(let j=i+1;j<bodies.length;j++){
cornerVsRect(bodies[i],bodies[j],cs);
cornerVsRect(bodies[j],bodies[i],cs);
}
return cs;
}
function cornerVsRect(a,r,cs){
const c=Math.cos(r.th), s=Math.sin(r.th), c2=Math.cos(a.th), s2=Math.sin(a.th);
for(let i=0;i<4;i++){
const lx=(i&1?a.hw:-a.hw), ly=(i&2?a.hh:-a.hh);
const px_=a.x+c2*lx-s2*ly, py_=a.y+s2*lx+c2*ly;
const dx=px_-r.x, dy=py_-r.y;
const lxx=dx*c+dy*s, lyy=-dx*s+dy*c;
if(Math.abs(lxx)<r.hw && Math.abs(lyy)<r.hh){
const ddx=r.hw-Math.abs(lxx), ddy=r.hh-Math.abs(lyy);
let nx,ny,depth;
if(ddx<ddy){ const sg=lxx>=0?1:-1; nx=c*sg; ny=s*sg; depth=ddx; }
else { const sg=lyy>=0?1:-1; nx=-s*sg; ny=c*sg; depth=ddy; }
cs.push({a,b:r,px:px_,py:py_,nx,ny,depth,mu:Math.min(a.muT,r.muT||0.4),e:0.12,svx:0,svy:0,surf:'body'});
}
}
}
/* ================= impulse solver ================= */
function solveContact(c, iter){
const a=c.a, b=c.b;
const rax=c.px-a.x, ray=c.py-a.y;
let rbx=0, rby=0;
let rvx=a.vx - a.om*ray, rvy=a.vy + a.om*rax;
if(b){ rbx=c.px-b.x; rby=c.py-b.y; rvx-=b.vx - b.om*rby; rvy-=b.vy + b.om*rbx; }
else { rvx-=c.svx; rvy-=c.svy; }
const vn=rvx*c.nx+rvy*c.ny;
c.jn=0;
if(vn<0){
const crA=rax*c.ny-ray*c.nx, crB=rbx*c.ny-rby*c.nx;
const kn=a.invM+(b?b.invM:0)+a.invI*crA*crA+(b?b.invI*crB*crB:0);
const e=vn<-1.0?c.e:0;
const jn=-(1+e)*vn/kn;
c.jn=jn;
const jx=c.nx*jn, jy=c.ny*jn;
a.vx+=jx*a.invM; a.vy+=jy*a.invM; a.om+=a.invI*(rax*jy-ray*jx);
if(b){ b.vx-=jx*b.invM; b.vy-=jy*b.invM; b.om-=b.invI*(rbx*jy-rby*jx); }
if(iter===0 && jn>0.35) events.push({px:c.px,py:c.py,jn,a,floor:c.surf==='floor'});
}
if(c.jn>0){ // Coulomb friction vs. surface velocity
let fvx=a.vx - a.om*ray, fvy=a.vy + a.om*rax;
if(b){ fvx-=b.vx - b.om*rby; fvy-=b.vy + b.om*rbx; } else { fvx-=c.svx; fvy-=c.svy; }
const vn2=fvx*c.nx+fvy*c.ny;
const vtx=fvx-vn2*c.nx, vty=fvy-vn2*c.ny;
const slip=Math.hypot(vtx,vty);
if(slip>1e-5){
const tx=vtx/slip, ty=vty/slip;
const ctA=rax*ty-ray*tx, ctB=rbx*ty-rby*tx;
const kt=a.invM+(b?b.invM:0)+a.invI*ctA*ctA+(b?b.invI*ctB*ctB:0);
let jt=slip/kt;
const maxF=c.mu*c.jn;
if(jt>maxF) jt=maxF; // static when slip is tiny, kinetic otherwise
const jx=-tx*jt, jy=-ty*jt;
a.vx+=jx*a.invM; a.vy+=jy*a.invM; a.om+=a.invI*(rax*jy-ray*jx);
if(b){ b.vx-=jx*b.invM; b.vy-=jy*b.invM; b.om-=b.invI*(rbx*jy-rby*jx); }
}
}
}
function correct(c){
const d=c.depth-0.0015; if(d<=0) return;
const corr=d*0.4, a=c.a, b=c.b;
if(b){ const w=a.invM+b.invM; if(w<=0) return;
a.x+=c.nx*corr*(a.invM/w); a.y+=c.ny*corr*(a.invM/w);
b.x-=c.nx*corr*(b.invM/w); b.y-=c.ny*corr*(b.invM/w);
} else { a.x+=c.nx*corr; a.y+=c.ny*corr; }
}
/* ================= simulation step ================= */
function physStep(dt){
simT+=dt;
const u=Math.min(1,simT/RAMP), sm=u*u*(3-2*u);
if(clothFront < TBL.x1+2.5){ clothV=pullV*sm; clothFront+=clothV*dt; } else clothV=0;
if(!clothOff && clothFront>TBL.x1){ clothOff=true;
ribbon={x:5.0,y:2.28,vx:Math.max(pullV,1),vy:0.4,ph:0}; }
for(const b of bodies){ b.vy-=G*dt; b.vx*=(1-0.006*dt); b.om*=(1-0.05*dt); }
const cs=gather();
for(let it=0; it<10; it++) for(const c of cs) solveContact(c,it);
for(const b of bodies){
b.x+=b.vx*dt; b.y+=b.vy*dt; b.th+=b.om*dt;
b.vx=clamp(b.vx,-80,80); b.vy=clamp(b.vy,-80,80); b.om=clamp(b.om,-40,40);
}
for(let k=0;k<2;k++) for(const c of cs) correct(c);
if(events.length){
let snd=2;
for(const ev of events){
if(ev.jn>(ev.floor?0.9:1.2)){
spawnDust(ev.px,ev.py,Math.min(7,2+Math.floor(ev.jn)),ev.floor);
if(snd>0){ impactSound(ev); snd--; }
}
}
events=[];
}
for(const b of bodies){
const w=wrapA(b.th);
if(!b.tipped && Math.abs(w)>1.0){ b.tipped=true; onTip(b); }
if(!b.spilled && (b.kind==='goblet'||b.kind==='cup') && Math.abs(w)>0.7){ b.spilled=true; spawnSpill(b); }
}
let mx=0; for(const b of bodies) mx=Math.max(mx,Math.abs(b.vx)+Math.abs(b.vy)+0.5*Math.abs(b.om));
if(clothOff && mx<0.06) still+=dt; else still=0;
if(still>0.5 || simT>60) finish();
}
/* ================= particles & sound ================= */
function spawnDust(x,y,n,floor){
for(let i=0;i<n;i++) particles.push({x:x+(Math.random()-0.5)*0.15, y:Math.max(0.02,y)+Math.random()*0.05,
vx:(Math.random()-0.5)*1.6, vy:0.4+Math.random()*1.2, r:0.025+Math.random()*0.035,
life:0.55+Math.random()*0.3, max:0.85, col:floor?'rgba(180,150,110,':'rgba(205,190,155,', g:10});
}
function spawnSpill(b){
for(let i=0;i<6;i++) particles.push({x:b.x+(Math.random()-0.5)*0.1, y:b.y+b.hh,
vx:b.vx*0.5+(Math.random()-0.5)*1.2, vy:b.vy*0.3+1+Math.random()*1.5,
r:0.02+Math.random()*0.025, life:0.9, max:0.9,
col:b.kind==='goblet'?'rgba(122,34,48,':'rgba(140,90,50,', g:G});
}
function onTip(b){
if(b.kind==='candle' && candleLit){
candleLit=false;
const fx=b.x-Math.sin(b.th)*0.42, fy=b.y+Math.cos(b.th)*0.42;
for(let i=0;i<6;i++) particles.push({x:fx,y:fy,vx:(Math.random()-0.5)*0.4,vy:0.5+Math.random()*0.5,
r:0.03+Math.random()*0.03,life:1.2,max:1.2,col:'rgba(130,120,110,',g:-1.5});
ping(220,0.35,0.10,'sine');
}
}
let AC=null, master=null;
function initAudio(){
if(AC) return;
try{ AC=new (window.AudioContext||window.webkitAudioContext)();
master=AC.createGain(); master.gain.value=0.35; master.connect(AC.destination);
}catch(e){ AC=null; }
}
function ping(freq,dur,vol,type){
if(!AC) return;
try{ const t=AC.currentTime, o=AC.createOscillator(), g=AC.createGain();
o.type=type||'sine'; o.frequency.value=freq;
g.gain.setValueAtTime(vol,t); g.gain.exponentialRampToValueAtTime(0.0008,t+dur);
o.connect(g); g.connect(master); o.start(t); o.stop(t+dur+0.02);
}catch(e){}
}
function whoosh(){
if(!AC) return;
try{ const sr=AC.sampleRate, buf=AC.createBuffer(1,sr*0.35,sr), d=buf.getChannelData(0);
for(let i=0;i<d.length;i++) d[i]=(Math.random()*2-1)*(1-i/d.length);
const src=AC.createBufferSource(); src.buffer=buf;
const f=AC.createBiquadFilter(); f.type='bandpass'; f.Q.value=0.8;
f.frequency.setValueAtTime(1400,AC.currentTime);
f.frequency.exponentialRampToValueAtTime(250,AC.currentTime+0.35);
const g=AC.createGain(); g.gain.value=0.5;
src.connect(f); f.connect(g); g.connect(master); src.start();
}catch(e){}
}
function impactSound(ev){
const now=performance.now(); if(now-lastSnd<70) return; lastSnd=now;
const base={plate:1500,goblet:2100,bowl:520,candle:300,cup:1700}[ev.a.kind]||800;
const vol=Math.min(0.4,0.06+ev.jn*0.05);
ping(base*(0.92+Math.random()*0.16),0.10,vol,'triangle');
if(ev.floor) ping(120,0.13,vol*0.9,'sine');
}
/* ================= results ================= */
function finish(){
if(state!=='pulling') return;
state='settled';
let on=0, up=0; const rows=[];
for(const b of bodies){
const cs=corners(b);
const minX=Math.min(cs[0].x,cs[1].x,cs[2].x,cs[3].x);
const maxX=Math.max(cs[0].x,cs[1].x,cs[2].x,cs[3].x);
const minY=Math.min(cs[0].y,cs[1].y,cs[2].y,cs[3].y);
const onT = minY>TBL.h-0.08 && minX>-0.06 && maxX<TBL.x1+0.06;
const upright = onT && Math.abs(wrapA(b.th))<0.3;
if(onT) on++; if(upright) up++;
const slid=Math.abs(b.x-b.x0);
const fate = !onT ? 'off the table' : !upright ? 'toppled'
: slid>0.22 ? 'slid '+slid.toFixed(1)+' ft' : 'barely moved';
rows.push('<li><span class="dot" style="background:'+KINDCOLOR[b.kind]+'"></span>'
+ b.name + '<em>' + fate + '</em></li>');
}
countsLine.innerHTML='<b>'+on+'</b> of '+bodies.length+' on the table · <b>'+up
+'</b> of '+bodies.length+' upright';
fatesEl.innerHTML=rows.join('');
let q;
if(on===bodies.length && up===bodies.length) q='A textbook yank — not a crumb out of place.';
else if(on===0) q='The cloth took the whole setting. Even the plates.';
else if(up===0) q='Everything still aboard is lying on its side.';
else if(on===bodies.length) q='Everything stayed aboard — but look at the wreckage.';
else q='The trick always claims a few victims.';
quipEl.textContent=q;
yankBtn.textContent='Reset to pull again';
resultsEl.classList.add('show');
}
/* ================= rendering ================= */
const WORLD={x0:-1.05,x1:6.55,y0:-0.45,y1:4.35};
const V={s:100,ox:0,oy:0,W:0,H:0};
function resize(){
const dpr=Math.min(2,window.devicePixelRatio||1);
V.W=window.innerWidth; V.H=window.innerHeight;
cv.width=V.W*dpr; cv.height=V.H*dpr;
cv.style.width=V.W+'px'; cv.style.height=V.H+'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
V.s=Math.min(V.W/(WORLD.x1-WORLD.x0), V.H/(WORLD.y1-WORLD.y0));
V.ox=(V.W-(WORLD.x1-WORLD.x0)*V.s)/2 - WORLD.x0*V.s;
V.oy=(V.H-(WORLD.y1-WORLD.y0)*V.s)/2 + WORLD.y1*V.s;
}
const px=x=>V.ox+x*V.s, py=y=>V.oy-y*V.s;
function rrW(x,y,w,h,r){
const S=V.s;
ctx.beginPath();
ctx.moveTo(px(x+r),py(y));
ctx.arcTo(px(x+w),py(y),px(x+w),py(y+h),r*S);
ctx.arcTo(px(x+w),py(y+h),px(x),py(y+h),r*S);
ctx.arcTo(px(x),py(y+h),px(x),py(y),r*S);
ctx.arcTo(px(x),py(y),px(x+w),py(y),r*S);
ctx.closePath();
}
function drawBackground(){
const W=V.W,H=V.H,s=V.s;
const g=ctx.createLinearGradient(0,0,0,py(0));
g.addColorStop(0,'#f3e8d0'); g.addColorStop(1,'#ead9b8');
ctx.fillStyle=g; ctx.fillRect(0,0,W,py(0));
ctx.strokeStyle='rgba(125,95,45,0.05)'; ctx.lineWidth=1; ctx.beginPath();
for(let x=Math.floor(WORLD.x0);x<WORLD.x1+1;x+=0.5){ ctx.moveTo(px(x),0); ctx.lineTo(px(x),py(0)); }
ctx.stroke();
ctx.fillStyle='#eedcb7'; ctx.fillRect(0,py(1.55),W,py(0)-py(1.55));
ctx.fillStyle='#e2cda2'; ctx.fillRect(0,py(1.66),W,0.11*s);
ctx.strokeStyle='rgba(120,90,40,0.07)'; ctx.beginPath();
for(let x=Math.floor(WORLD.x0);x<WORLD.x1+1;x+=0.62){ ctx.moveTo(px(x),py(1.55)); ctx.lineTo(px(x),py(0.16)); }
ctx.stroke();
ctx.fillStyle='#cdb586'; ctx.fillRect(0,py(0.16),W,0.16*s);
ctx.fillStyle='rgba(90,60,25,0.25)'; ctx.fillRect(0,py(0.16),W,1.5);
ctx.fillStyle='#a87c4e'; ctx.fillRect(0,py(0),W,H-py(0));
ctx.strokeStyle='rgba(80,50,20,0.22)'; ctx.beginPath();
for(let y=-0.2;y>WORLD.y0-0.5;y-=0.22){ ctx.moveTo(0,py(y)); ctx.lineTo(W,py(y)); }
ctx.stroke();
ctx.fillStyle='#9c4a3c'; rrW(-0.3,0.005,6.5,0.12,0.05); ctx.fill();
ctx.strokeStyle='rgba(250,240,215,0.5)'; ctx.lineWidth=1.5; ctx.stroke();
ctx.strokeStyle='rgba(250,240,215,0.3)'; rrW(-0.2,0.025,6.3,0.075,0.03); ctx.stroke();
drawPicture(); drawClock();
}
function drawPicture(){
const x=0.78,y=3.02,w=1.1,h=0.86;
ctx.fillStyle='#7a5230'; rrW(x,y,w,h,0.04); ctx.fill();
ctx.fillStyle='#f6efdd'; rrW(x+0.07,y+0.07,w-0.14,h-0.14,0.02); ctx.fill();
ctx.strokeStyle='#8aa27c'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(px(x+0.12),py(y+0.30));
ctx.quadraticCurveTo(px(x+0.42),py(y+0.62),px(x+0.62),py(y+0.32)); ctx.stroke();
ctx.beginPath(); ctx.moveTo(px(x+0.45),py(y+0.32));
ctx.quadraticCurveTo(px(x+0.75),py(y+0.55),px(x+w-0.12),py(y+0.28)); ctx.stroke();
ctx.fillStyle='#d9a441'; ctx.beginPath(); ctx.arc(px(x+0.82),py(y+0.62),0.06*V.s,0,7); ctx.fill();
}
function drawClock(){
const cx=px(5.95), cy=py(3.55), r=0.27*V.s;
ctx.fillStyle='#f6efdd'; ctx.beginPath(); ctx.arc(cx,cy,r,0,7); ctx.fill();
ctx.strokeStyle='#7a5230'; ctx.lineWidth=0.05*V.s; ctx.stroke();
ctx.lineWidth=2;
for(let i=0;i<4;i++){ const a=i*Math.PI/2;
ctx.beginPath(); ctx.moveTo(cx+Math.cos(a)*r*0.78,cy+Math.sin(a)*r*0.78);
ctx.lineTo(cx+Math.cos(a)*r*0.95,cy+Math.sin(a)*r*0.95); ctx.stroke(); }
const ma=-Math.PI/2+simT*(Math.PI/30), ha=-Math.PI/2+simT*(Math.PI/360);
ctx.lineWidth=3; ctx.beginPath(); ctx.moveTo(cx,cy);
ctx.lineTo(cx+Math.cos(ma)*r*0.72,cy+Math.sin(ma)*r*0.72); ctx.stroke();
ctx.lineWidth=4; ctx.beginPath(); ctx.moveTo(cx,cy);
ctx.lineTo(cx+Math.cos(ha)*r*0.5,cy+Math.sin(ha)*r*0.5); ctx.stroke();
ctx.fillStyle='#7a5230'; ctx.beginPath(); ctx.arc(cx,cy,3,0,7); ctx.fill();
}
function leg(x,col){
const s=V.s;
ctx.fillStyle=col; ctx.beginPath();
ctx.moveTo(px(x-0.065),py(TBL.h-TBL.th)); ctx.lineTo(px(x+0.065),py(TBL.h-TBL.th));
ctx.lineTo(px(x+0.05),py(0.01)); ctx.lineTo(px(x-0.05),py(0.01));
ctx.closePath(); ctx.fill();
}
function drawTable(){
const s=V.s;
ctx.fillStyle='rgba(60,38,18,0.16)';
ctx.beginPath(); ctx.ellipse(px(2.5),py(0)+0.05*s,2.75*s,0.11*s,0,0,7); ctx.fill();
leg(0.62,'#b5854f'); leg(4.38,'#b5854f');
ctx.fillStyle='#7e5630'; ctx.fillRect(px(0.08),py(TBL.h-TBL.th-0.09),4.84*s,0.09*s);
leg(0.42,'#8a5f33'); leg(4.58,'#8a5f33');
ctx.fillStyle='#9a6b3f'; ctx.fillRect(px(0),py(TBL.h),5*s,TBL.th*s);
ctx.strokeStyle='rgba(80,50,20,0.18)'; ctx.lineWidth=1; ctx.beginPath();
for(let i=0;i<3;i++){ const y=TBL.h-0.03-i*0.045;
ctx.moveTo(px(0.1),py(y));
ctx.bezierCurveTo(px(1.5),py(y+0.02),px(3.5),py(y-0.02),px(4.9),py(y)); }
ctx.stroke();
ctx.fillStyle='rgba(255,230,180,0.35)'; ctx.fillRect(px(0),py(TBL.h),5*s,1.5);
ctx.fillStyle='rgba(60,38,18,0.35)'; ctx.fillRect(px(0),py(TBL.h-TBL.th)+TBL.th*s-1.5,5*s,1.5);
}
function drawCloth(){
const s=V.s, p=Math.min(clothFront,TBL.x1);
if(p<TBL.x1){
ctx.fillStyle='#f7f2e4'; ctx.fillRect(px(p),py(TBL.h+0.055),(TBL.x1-p)*s,0.055*s);
ctx.fillStyle='rgba(181,84,59,0.5)'; ctx.fillRect(px(p),py(TBL.h+0.040),(TBL.x1-p)*s,0.012*s);
ctx.fillStyle='rgba(122,140,90,0.45)'; ctx.fillRect(px(p),py(TBL.h+0.018),(TBL.x1-p)*s,0.012*s);
if(p>0.01){ ctx.fillStyle='#e6dcc2'; ctx.fillRect(px(p)-0.03*s,py(TBL.h+0.055),0.035*s,0.055*s); }
}
drawDrape();
}
function handY(){ return 1.32+Math.sin(wallT*5)*0.012; }
function drawDrape(){
const s=V.s, p=clothFront;
if(!clothOff){
const hx=5.55+p, hy=handY();
const hang=HANG0+Math.min(p,TBL.x1);
const dip=Math.max(0.06,TBL.h-Math.min(hang*0.85,2.3));
ctx.lineCap='round';
ctx.strokeStyle='rgba(90,70,40,0.25)'; ctx.lineWidth=0.085*s;
ctx.beginPath(); ctx.moveTo(px(4.98),py(TBL.h+0.03));
ctx.quadraticCurveTo(px(5.3),py(dip-0.03),px(hx),py(hy-0.02)); ctx.stroke();
ctx.strokeStyle='#f7f2e4'; ctx.lineWidth=0.08*s;
ctx.beginPath(); ctx.moveTo(px(5.0),py(TBL.h+0.03));
ctx.quadraticCurveTo(px(5.3),py(dip),px(hx),py(hy)); ctx.stroke();
ctx.strokeStyle='rgba(160,140,100,0.35)'; ctx.lineWidth=0.012*s;
ctx.beginPath(); ctx.moveTo(px(5.05),py(TBL.h-0.1));
ctx.quadraticCurveTo(px(5.28),py(dip+0.3),px(hx-0.06),py(hy+0.05)); ctx.stroke();
if(hang>2.1){
const ex=hang-2.1;
ctx.fillStyle='#f2ecda'; ctx.strokeStyle='rgba(160,140,100,0.4)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.ellipse(px(5.45),py(0.05),(0.30+ex*0.12)*s,0.06*s,0,0,7); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.ellipse(px(5.85),py(0.045),(0.24+ex*0.10)*s,0.05*s,0,0,7); ctx.fill(); ctx.stroke();
}
} else if(ribbon){
const L=Math.hypot(ribbon.vx,ribbon.vy)||1, dx=ribbon.vx/L, dy=ribbon.vy/L;
ctx.strokeStyle='#f7f2e4'; ctx.lineCap='round';
for(let i=0;i<10;i++){
const t0=i/10, t1=(i+1)/10;
const w0=Math.sin(ribbon.ph-t0*5)*0.10*t0, w1=Math.sin(ribbon.ph-t1*5)*0.10*t1;
const x0=ribbon.x-dx*t0*2.2-dy*w0, y0=ribbon.y-dy*t0*2.2+dx*w0;
const x1=ribbon.x-dx*t1*2.2-dy*w1, y1=ribbon.y-dy*t1*2.2+dx*w1;
ctx.lineWidth=Math.max(1,0.08*s*(1-t0*0.6));
ctx.beginPath(); ctx.moveTo(px(x0),py(y0)); ctx.lineTo(px(x1),py(y1)); ctx.stroke();
}
}
}
function drawHand(){
const s=V.s, p=clothFront, hx=5.55+p, hy=handY();
if(p>=TBL.x1 || hx>7.4) return;
ctx.lineCap='round';
ctx.strokeStyle='#b5543b'; ctx.lineWidth=0.17*s;
ctx.beginPath(); ctx.moveTo(px(hx+1.5),py(hy+0.55)); ctx.lineTo(px(hx+0.10),py(hy+0.04)); ctx.stroke();
ctx.strokeStyle='#e8d9b8';
ctx.beginPath(); ctx.moveTo(px(hx+0.34),py(hy+0.16)); ctx.lineTo(px(hx+0.14),py(hy+0.06)); ctx.stroke();
ctx.fillStyle='#e9b98d';
ctx.beginPath(); ctx.arc(px(hx),py(hy),0.10*s,0,7); ctx.fill();
for(let i=-1;i<=1;i++){ ctx.beginPath(); ctx.arc(px(hx+i*0.05),py(hy-0.065),0.032*s,0,7); ctx.fill(); }
ctx.beginPath(); ctx.arc(px(hx+0.01),py(hy+0.07),0.036*s,0,7); ctx.fill();
}
function drawShadow(b){
let sy=null; const bot=b.y-b.hh;
if(b.x>TBL.x0-0.15 && b.x<TBL.x1+0.15 && Math.abs(bot-(TBL.h+CLOTH_TH))<0.15) sy=TBL.h+CLOTH_TH;
else if(bot<0.3) sy=0.012;
if(sy===null) return;
const a=0.16*clamp(1-(bot-sy)*1.5,0.25,1);
const rx=(b.hw*Math.abs(Math.cos(b.th))+b.hh*Math.abs(Math.sin(b.th)))*2.1;
ctx.fillStyle='rgba(70,45,20,'+a.toFixed(3)+')';
ctx.beginPath(); ctx.ellipse(px(b.x),py(sy)+1,rx*V.s,0.045*V.s,0,0,7); ctx.fill();
}
/* ---- object art (local feet coords, y up) ---- */
function drawPlate(){
ctx.beginPath();
ctx.moveTo(-0.45,-0.035); ctx.lineTo(0.45,-0.035); ctx.lineTo(0.45,0.002);
ctx.quadraticCurveTo(0.45,0.05,0.34,0.05); ctx.lineTo(-0.34,0.05);
ctx.quadraticCurveTo(-0.45,0.05,-0.45,0.002); ctx.closePath();
ctx.fillStyle='#f8f4ea'; ctx.fill();
ctx.lineWidth=0.014; ctx.strokeStyle='#b9a888'; ctx.stroke();
ctx.beginPath(); ctx.moveTo(-0.35,0.028); ctx.lineTo(0.35,0.028);
ctx.lineWidth=0.02; ctx.strokeStyle='#5b7b93'; ctx.stroke();
ctx.beginPath(); ctx.ellipse(0,-0.012,0.22,0.012,0,0,7);
ctx.strokeStyle='rgba(150,140,110,.35)'; ctx.lineWidth=0.01; ctx.stroke();
}
function drawGoblet(){
ctx.lineWidth=0.012; ctx.strokeStyle='#8fa3ad';
ctx.beginPath(); ctx.ellipse(0,-0.285,0.10,0.025,0,0,7);
ctx.fillStyle='rgba(190,215,225,.5)'; ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-0.016,-0.285); ctx.lineTo(-0.012,0.06);
ctx.lineTo(0.012,0.06); ctx.lineTo(0.016,-0.285); ctx.closePath();
ctx.fillStyle='rgba(190,215,225,.45)'; ctx.fill(); ctx.stroke();
const bowl=()=>{ ctx.beginPath(); ctx.moveTo(-0.105,0.30);
ctx.quadraticCurveTo(-0.12,0.10,0,0.075); ctx.quadraticCurveTo(0.12,0.10,0.105,0.30); ctx.closePath(); };
bowl(); ctx.fillStyle='rgba(205,228,238,.30)'; ctx.fill();
ctx.lineWidth=0.014; ctx.stroke();
ctx.save(); bowl(); ctx.clip();
ctx.fillStyle='#7a2230'; ctx.fillRect(-0.12,0.075,0.24,0.075);
ctx.fillStyle='rgba(255,255,255,.15)'; ctx.fillRect(-0.12,0.143,0.24,0.008);
ctx.restore();
ctx.beginPath(); ctx.ellipse(0,0.30,0.105,0.018,0,0,7);
ctx.strokeStyle='#8fa3ad'; ctx.lineWidth=0.012; ctx.stroke();
}
function drawBowl(){
ctx.lineWidth=0.012; ctx.strokeStyle='#a5946f';
ctx.beginPath(); ctx.ellipse(0,-0.145,0.14,0.03,0,0,7);
ctx.fillStyle='#dccfae'; ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-0.30,0.15);
ctx.bezierCurveTo(-0.30,-0.02,-0.16,-0.15,0,-0.15);
ctx.bezierCurveTo(0.16,-0.15,0.30,-0.02,0.30,0.15); ctx.closePath();
ctx.fillStyle='#eee2c6'; ctx.fill(); ctx.lineWidth=0.016; ctx.stroke();
ctx.beginPath(); ctx.ellipse(0,0.15,0.295,0.05,0,0,7);
ctx.fillStyle='#c9b790'; ctx.fill(); ctx.lineWidth=0.012; ctx.stroke();
ctx.beginPath(); ctx.arc(-0.09,0.165,0.075,0,7);
ctx.fillStyle='#d98e3a'; ctx.fill(); ctx.strokeStyle='#a3631f'; ctx.lineWidth=0.01; ctx.stroke();
ctx.beginPath(); ctx.arc(0.09,0.175,0.07,0,7);
ctx.fillStyle='#cf7f2f'; ctx.fill(); ctx.stroke();
}
function drawCandle(b){
ctx.lineWidth=0.012; ctx.strokeStyle='#7d5c1c';
ctx.beginPath(); ctx.ellipse(0,-0.44,0.15,0.038,0,0,7);
ctx.fillStyle='#c09338'; ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-0.055,-0.44); ctx.lineTo(-0.028,0.10);
ctx.lineTo(0.028,0.10); ctx.lineTo(0.055,-0.44); ctx.closePath();
ctx.fillStyle='#c09338'; ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-0.05,0.10); ctx.lineTo(-0.062,0.17);
ctx.lineTo(0.062,0.17); ctx.lineTo(0.05,0.10); ctx.closePath();
ctx.fillStyle='#a87e2b'; ctx.fill(); ctx.stroke();
ctx.fillStyle='#f3ead6'; ctx.fillRect(-0.034,0.17,0.068,0.22);
ctx.strokeStyle='#c9bfa4'; ctx.lineWidth=0.01; ctx.strokeRect(-0.034,0.17,0.068,0.22);
ctx.beginPath(); ctx.moveTo(0,0.39); ctx.lineTo(0,0.415);
ctx.strokeStyle='#5a4a2a'; ctx.lineWidth=0.012; ctx.stroke();
if(candleLit && !b.tipped){
const f=1+0.18*Math.sin(wallT*23)+0.12*Math.sin(wallT*47+1.7);
ctx.beginPath(); ctx.moveTo(0,0.415);
ctx.quadraticCurveTo(0.028*f,0.44,0,0.415+0.075*f);
ctx.quadraticCurveTo(-0.028*f,0.44,0,0.415);
ctx.fillStyle='#f2a83b'; ctx.fill();
ctx.beginPath(); ctx.moveTo(0,0.418);
ctx.quadraticCurveTo(0.014*f,0.435,0,0.418+0.045*f);
ctx.quadraticCurveTo(-0.014*f,0.435,0,0.418);
ctx.fillStyle='#fde9a8'; ctx.fill();
}
}
function drawCup(){
ctx.lineWidth=0.01; ctx.strokeStyle='#b9a888';
ctx.beginPath(); ctx.ellipse(0,-0.105,0.105,0.024,0,0,7);
ctx.fillStyle='#f0e7d2'; ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-0.095,0.115);
ctx.quadraticCurveTo(-0.10,-0.02,-0.06,-0.075); ctx.lineTo(0.06,-0.075);
ctx.quadraticCurveTo(0.10,-0.02,0.095,0.115); ctx.closePath();
ctx.fillStyle='#f6f0e0'; ctx.fill(); ctx.lineWidth=0.012; ctx.stroke();
ctx.beginPath(); ctx.moveTo(-0.088,0.02); ctx.lineTo(0.088,0.02);
ctx.strokeStyle='#c76b8f'; ctx.lineWidth=0.018; ctx.stroke();
ctx.beginPath(); ctx.ellipse(0,0.104,0.082,0.016,0,0,7);
ctx.fillStyle='#8a5a33'; ctx.fill();
ctx.beginPath(); ctx.arc(0.10,0.02,0.05,-1.2,1.2);
ctx.strokeStyle='#b9a888'; ctx.lineWidth=0.022; ctx.stroke();
}
const DRAW={plate:drawPlate, goblet:drawGoblet, bowl:drawBowl, candle:drawCandle, cup:drawCup};
function drawBody(b){
ctx.save();
ctx.translate(px(b.x),py(b.y)); ctx.rotate(-b.th); ctx.scale(V.s,-V.s);
ctx.lineJoin='round'; ctx.lineCap='round';
DRAW[b.kind](b);
ctx.restore();
}
function drawParticles(){
for(const p of particles){
ctx.fillStyle=p.col+(clamp(p.life/p.max,0,1)*0.85).toFixed(3)+')';
ctx.beginPath(); ctx.arc(px(p.x),py(p.y),Math.max(1,p.r*V.s),0,7); ctx.fill();
}
}
function speedLines(){
const a=clamp((clothV-6)/20,0,0.5); if(a<=0.02) return;
const p=Math.min(clothFront,TBL.x1);
ctx.strokeStyle='rgba(120,100,70,'+a.toFixed(2)+')'; ctx.lineWidth=2; ctx.lineCap='round';
ctx.beginPath();
const segs=[[p-1.2,TBL.h+0.3,p-0.5],[p-1.8,TBL.h+0.6,p-1.0],[p-0.9,TBL.h+0.95,p-0.3]];
for(const sg of segs) if(sg[0]>0.2){ ctx.moveTo(px(sg[0]),py(sg[1])); ctx.lineTo(px(sg[2]),py(sg[1])); }
ctx.stroke();
}
function draw(){
drawBackground();
drawTable();
drawCloth();
for(const b of bodies) drawShadow(b);
for(const b of bodies) drawBody(b);
drawHand();
drawParticles();
speedLines();
}
/* ================= UI & main loop ================= */
function currentSpeed(){ return clamp(parseFloat(speedNum.value)||12,0.5,30); }
function doYank(){
if(state!=='ready') return;
initAudio();
if(AC && AC.state==='suspended') AC.resume().catch(()=>{});
whoosh();
pullV=currentSpeed();
state='pulling'; simT=0; still=0; acc=0;
yankBtn.disabled=true; yankBtn.textContent='Pulling…';
}
yankBtn.addEventListener('click',doYank);
resetBtn.addEventListener('click',reset);
speedRange.addEventListener('input',()=>{ speedNum.value=speedRange.value; });
speedNum.addEventListener('change',()=>{ const v=currentSpeed(); speedNum.value=v; speedRange.value=v; });
window.addEventListener('keydown',e=>{
if(e.repeat) return;
const k=e.key.toLowerCase();
if(k==='y'||k===' '){ e.preventDefault(); doYank(); }
else if(k==='r'){ reset(); }
});
window.addEventListener('pointerdown',initAudio,{once:true});
window.addEventListener('resize',resize);
let lastStatus='';
function setStatus(t,busy){
if(t!==lastStatus){ statusEl.textContent=t; lastStatus=t; }
statusEl.classList.toggle('busy',!!busy);
}
function updateStatus(){
if(state==='pulling'){
if(clothFront<TBL.x1) setStatus('Pulling at '+pullV.toFixed(1)+' ft/s — the cloth is sweeping the table…',true);
else setStatus('Cloth is off — waiting for everything to stop…',true);
} else if(state==='settled') setStatus('Settled after '+simT.toFixed(1)+' s — press Reset to set the table again.',false);
else setStatus('Ready — set a pull speed, then yank.',false);
}
let last=performance.now(), acc=0;
function frame(now){
const fdt=Math.min(0.05,(now-last)/1000)||0.016; last=now;
wallT+=fdt;
if(state==='pulling'){
acc+=fdt; let n=0;
while(acc>=DT && n<40){ physStep(DT); acc-=DT; n++; if(state!=='pulling') break; }
}
for(let i=particles.length-1;i>=0;i--){
const p=particles[i];
p.vy-=p.g*fdt; p.x+=p.vx*fdt; p.y+=p.vy*fdt; p.life-=fdt;
if(p.y<0.01 && p.vy<0){ p.y=0.01; p.vy*=-0.25; p.vx*=0.6; }
if(p.life<=0) particles.splice(i,1);
}
if(ribbon){
ribbon.vy-=G*0.45*fdt; ribbon.x+=ribbon.vx*fdt; ribbon.y+=ribbon.vy*fdt; ribbon.ph+=fdt*7;
if(ribbon.y<-2 || ribbon.x>WORLD.x1+3) ribbon=null;
}
draw();
updateStatus();
requestAnimationFrame(frame);
}
/* ================= boot: #speed=12 sets speed and yanks after 1 s ================= */
reset();
resize();
(function(){
const m=location.hash.match(/speed\s*=\s*([0-9]*\.?[0-9]+)/i);
if(m){
const v=clamp(parseFloat(m[1]),0.5,30);
speedRange.value=v; speedNum.value=v;
setStatus('Auto-yank at '+v+' ft/s in 1 second…',true);
setTimeout(doYank,1000);
}
})();
requestAnimationFrame(frame);
</script>
</body>
</html>
Where it's the real trick. This is a genuine 2D rigid-body simulation, not a lookup table keyed to speed: an impulse solver (600 Hz substeps) with Coulomb friction, gravity, rotational inertia, and corner contacts. The pull speed enters only as the velocity of the cloth's surface, so everything in the brief emerges: slow pulls let objects stick to (or catch up with) the cloth and ride off the near edge; fast yanks leave the cloth under them for a few hundredths of a second and they barely shiver; in between you get the good middle — plates sliding, the candlestick rocking back on its trailing edge, objects pivoting over the table edge, tumbling to the floor, bouncing, and bowling into each other (the heavy candlestick captures the cloth's speed sooner than the glass ahead of it and can literally ram it off). The cloth edge also passes under objects partially, so an object straddling the receding hem tilts as it loses support on one side. Same speed, same outcome — the physics is deterministic; only the dust and sounds are random.
Where it departs from reality. The cloth is a massless, inextensible kinematic belt moving at exactly the set speed after a 0.12 s ramp — no stretch, no wave propagation, no lifting of objects, no cloth-on-floor friction once it piles up. Objects are rigid rectangles with painted-on roundness: nothing rolls or spins like a disc, and it's a side view, so sideways slips and cloth-twist failures don't exist. Restitutions and floor friction are educated guesses. The wine is decorative until a glass tips, then it's a particle puff, not liquid.
Scripted garnish (not physics): the candle flame goes out with a smoke puff if the candlestick tips past ~57°, dust puffs and synthesized clinks/thuds fire on hard impacts (audio needs a user gesture, so the #speed=12 auto-pull may be silent), the wall clock runs on simulation time, and the cloth drape/ribbon is pure animation.
How it fails before you find out. The boundaries are knife-edge: the hand arithmetic says nothing stays below ≈3.3 ft/s and everything stays above ≈10.3 ft/s, but discrete corner impulses and the ramp shift the measured cliffs by a few tenths, so a goblet may survive at 10.1 or slide off at 10.4. Around 11–12 ft/s the candlestick sits exactly on its tipping boundary — expect it to rock violently and occasionally topple while everything else is pristine. At mid speeds objects can pile up at the near edge, wedge against a table leg, or balance on the rim for a moment before going over; a neighbor can knock something off rather than the cloth itself. Very slow pulls take 10–15 s to play out fully, since the sim honestly waits for the last plate to creep off the edge and stop.