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.
With $g=32.2,{\rm ft/s^2}$, table $12,{\rm ft}$ wide, cloth $13,{\rm ft}$ long with trailing edge initially at $-0.5,{\rm ft}$ so $L_i=x_{0i}+0.5,{\rm ft}$, table friction $\mu_t=0.25$, and objects Plate L $x=2.0,w=0.90,h=0.14,m=0.70,\mu_c=0.18$, Glass L $x=3.4,w=0.30,h=0.90,m=0.50,\mu_c=0.20$, Bowl $x=5.0,w=0.85,h=0.50,m=1.40,\mu_c=0.32$, Candlestick $x=6.6,w=0.36,h=1.10,m=1.80,\mu_c=0.335$, Cup $x=7.4,w=0.40,h=0.60,m=0.90,\mu_c=0.26$, Glass R $x=8.2,w=0.28,h=0.95,m=0.50,\mu_c=0.300$, Plate R $x=9.4,w=0.90,h=0.14,m=0.70,\mu_c=0.18$, cloth friction gives impulse $J=\mu_c m g L_i/v$, $\Delta v=J/m=\mu_c g L_i/v$ (mass cancels), an object catches the cloth and rides off iff $\Delta v\ge v$, i.e. $v\le\sqrt{\mu_c g L_i}$, giving $3.81,5.01,7.53,8.75,8.13,9.17,7.58,{\rm ft/s}$, and otherwise slides on the table $s=\Delta v^{2}/(2\mu_t g)$ and stays iff $s<d_i=12-(x_{0i}+w_i/2)$, i.e. $v>\mu_c g L_i/\sqrt{2\mu_t g d_i}$, giving $1.17,2.15,5.51,8.35,7.86,10.94,9.76,{\rm ft/s}$, so the lowest speed at which every object stays on the table is $\max_i\max(\sqrt{\mu_c g L_i},\mu_c g L_i/\sqrt{2\mu_t g d_i})=10.9,{\rm ft/s}$ (Glass R, slide-limited) and the speed below which no object stays on the table is $\min_i\sqrt{\mu_c g L_i}=3.8,{\rm ft/s}$ (Plate L, drag-limited).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Crouton Alpha — Tablecloth Trick</title>
<style>
body{margin:0;background:#2b2118;font-family:system-ui,Arial,sans-serif;color:#f5ead6}
#wrap{max-width:1020px;margin:0 auto;padding:12px}
h1{font-size:20px;margin:6px 0 10px}
#controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center;background:#3a2d1f;padding:10px;border-radius:8px}
button{background:#d64545;color:#fff;border:0;padding:8px 14px;border-radius:6px;font-weight:700;cursor:pointer}
button.secondary{background:#6b543a}
input[type=range]{width:220px}
input[type=number]{width:70px;padding:6px;border-radius:6px;border:0}
canvas{width:100%;height:auto;display:block;margin-top:10px;background:#f7e8c9;border-radius:8px}
#status{background:#1f1810;padding:10px;border-radius:8px;margin-top:10px;min-height:22px}
.small{font-size:13px;opacity:.85}
</style>
</head>
<body>
<div id="wrap">
<h1>Crouton Alpha — Tablecloth Trick</h1>
<div id="controls">
<label>Pull speed <span id="speedLabel">8.0</span> ft/s</label>
<input id="speedRange" type="range" min="0.5" max="25" step="0.1" value="8">
<input id="speedNum" type="number" min="0" step="0.1" value="8">
<button id="yankBtn">Yank the cloth</button>
<button id="resetBtn" class="secondary">Reset</button>
<span class="small">Slow drags the setting off. Fast leaves it standing.</span>
</div>
<canvas id="cv" width="1000" height="560"></canvas>
<div id="status">Ready. Set pull speed and yank the cloth.</div>
<div class="small">7 objects: 2 plates, 2 glasses, bowl, candlestick, cup. Mass, width, height and cloth friction differ per object. Physics: friction + inertia only, cloth speed is prescribed.</div>
</div>
<script>
const G=32.2, TABLE_W=12, TABLE_Y=0, FLOOR_Y=-2.5, MU_TABLE=0.25, MU_FLOOR=0.25;
const CLOTH_LEN=13, CLOTH_X0=-0.5;
const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
const statusEl=document.getElementById('status');
const rangeEl=document.getElementById('speedRange'), numEl=document.getElementById('speedNum'), labelEl=document.getElementById('speedLabel');
let pullSpeed=8, clothX=CLOTH_X0, clothActive=false, yanked=false, stopped=false, stillTime=0, simTime=0;
const INIT=[
{name:'Plate L',type:'plate',x:2.0,w:0.90,h:0.14,m:0.70,mu:0.18,col:'#ffffff'},
{name:'Glass L',type:'glass',x:3.4,w:0.30,h:0.90,m:0.50,mu:0.20,col:'#a8d8ea'},
{name:'Bowl',type:'bowl',x:5.0,w:0.85,h:0.50,m:1.40,mu:0.32,col:'#e67e22'},
{name:'Candlestick',type:'candle',x:6.6,w:0.36,h:1.10,m:1.80,mu:0.335,col:'#f1c40f'},
{name:'Cup',type:'cup',x:7.4,w:0.40,h:0.60,m:0.90,mu:0.26,col:'#c0392b'},
{name:'Glass R',type:'glass',x:8.2,w:0.28,h:0.95,m:0.50,mu:0.300,col:'#a8d8ea'},
{name:'Plate R',type:'plate',x:9.4,w:0.90,h:0.14,m:0.70,mu:0.18,col:'#ffffff'}
];
let objects=[];
function reset(){
objects=INIT.map(o=>({name:o.name,type:o.type,x:o.x,y:TABLE_Y+o.h/2,vx:0,vy:0,theta:0,omega:0,w:o.w,h:o.h,m:o.m,mu:o.mu,col:o.col,grounded:true,surfaceY:TABLE_Y,fallen:false,fallenTheta:0,clothContact:false}));
clothX=CLOTH_X0; clothActive=false; yanked=false; stopped=false; stillTime=0; simTime=0;
statusEl.textContent='Ready. Set pull speed and yank the cloth.';
}
function setSpeed(v){
pullSpeed=v; labelEl.textContent=(Math.round(v*10)/10).toFixed(1);
numEl.value=v; if(v>=0.5&&v<=25) rangeEl.value=v;
}
rangeEl.addEventListener('input',()=>setSpeed(parseFloat(rangeEl.value)));
numEl.addEventListener('change',()=>setSpeed(parseFloat(numEl.value)||0));
document.getElementById('yankBtn').addEventListener('click',yank);
document.getElementById('resetBtn').addEventListener('click',reset);
function yank(){
if(yanked) return;
yanked=true; clothActive=true; stopped=false; stillTime=0;
statusEl.textContent='Cloth pulling at '+pullSpeed.toFixed(1)+' ft/s ...';
}
function step(dt){
simTime+=dt;
if(clothActive){
clothX+=pullSpeed*dt;
if(clothX>18) clothActive=false;
}
for(const o of objects){
if(o.grounded){
if(o.surfaceY===TABLE_Y && (o.x<0||o.x>TABLE_W)){
o.grounded=false; o.vy=0;
}else{
let vSurf=0, mu=MU_TABLE, clothContact=false;
if(o.surfaceY===TABLE_Y && clothActive){
const left=o.x-o.w/2, right=o.x+o.w/2;
if(right>clothX && left<clothX+CLOTH_LEN){vSurf=pullSpeed; mu=o.mu; clothContact=true;}
}else if(o.surfaceY===FLOOR_Y){mu=MU_FLOOR;}
o.clothContact=clothContact;
let vRel=o.vx-vSurf, Fx=0;
if(vRel>1e-9) Fx=-mu*o.m*G; else if(vRel<-1e-9) Fx=mu*o.m*G;
const vxOld=o.vx;
o.vx+=Fx/o.m*dt;
if((vRel>0&&o.vx<=vSurf)||(vRel<0&&o.vx>=vSurf)) o.vx=vSurf;
const a=(o.vx-vxOld)/dt;
o.x+=o.vx*dt;
if(o.fallen){o.theta=o.fallenTheta; o.omega=0;}
else{
const aTip=G*o.w/o.h;
const pivoting=Math.abs(o.theta)>1e-4||Math.abs(o.omega)>1e-4||Math.abs(a)>aTip;
if(pivoting){
let pivotLeft;
if(o.theta>0.01) pivotLeft=true;
else if(o.theta<-0.01) pivotLeft=false;
else if(Math.abs(o.omega)>0.01) pivotLeft=o.omega>0;
else pivotLeft=a>0;
let rx,ry;
if(clothContact){rx=pivotLeft?-o.w/2:o.w/2; ry=-o.h/2;}
else{
const ct=Math.cos(o.theta), st=Math.sin(o.theta);
if(pivotLeft){rx=(-o.w/2)*ct-(-o.h/2)*st; ry=(-o.w/2)*st+(-o.h/2)*ct;}
else{rx=(o.w/2)*ct-(-o.h/2)*st; ry=(o.w/2)*st+(-o.h/2)*ct;}
}
const prevTheta=o.theta;
const N=o.m*G, FxA=o.m*a;
const tau=N*rx-FxA*ry;
const Icom=o.m*(o.w*o.w+o.h*o.h)/12;
let alpha=tau/Icom - o.omega*1.2;
o.omega+=alpha*dt; o.theta+=o.omega*dt;
if(o.theta>=Math.PI/2){o.theta=Math.PI/2;o.omega=0;o.fallen=true;o.fallenTheta=Math.PI/2;}
else if(o.theta<=-Math.PI/2){o.theta=-Math.PI/2;o.omega=0;o.fallen=true;o.fallenTheta=-Math.PI/2;}
else if(prevTheta!==0 && ((prevTheta>0&&o.theta<=0)||(prevTheta<0&&o.theta>=0))){
o.theta=0;
if(Math.abs(o.omega)<0.8) o.omega=0; else o.omega=-o.omega*0.35;
}
}else{o.theta=0;o.omega=0;}
}
if(o.fallen) o.y=o.surfaceY+o.w/2;
else o.y=o.surfaceY+(o.w/2)*Math.abs(Math.sin(o.theta))+(o.h/2)*Math.cos(o.theta);
o.vy=0;
}
}
if(!o.grounded){
o.vy-=G*dt; o.y+=o.vy*dt; o.x+=o.vx*dt; o.theta+=o.omega*dt;
if(o.theta>Math.PI/2){o.theta=Math.PI/2;o.omega=0;o.fallen=true;o.fallenTheta=Math.PI/2;}
if(o.theta<-Math.PI/2){o.theta=-Math.PI/2;o.omega=0;o.fallen=true;o.fallenTheta=-Math.PI/2;}
const height=o.fallen?o.w/2:(o.w/2)*Math.abs(Math.sin(o.theta))+(o.h/2)*Math.cos(o.theta);
const landY=(o.x>=0&&o.x<=TABLE_W)?TABLE_Y:FLOOR_Y;
if(o.y-height<=landY&&o.vy<0){
o.y=landY+height; o.vy=0; o.grounded=true; o.surfaceY=landY; o.vx*=0.75; o.omega*=0.5;
}
}
}
// collisions grounded-grounded
for(let i=0;i<objects.length;i++) for(let j=i+1;j<objects.length;j++){
const a=objects[i], b=objects[j];
if(a.grounded&&b.grounded&&a.surfaceY===b.surfaceY){
const wa=a.fallen?a.h:a.w, wb=b.fallen?b.h:b.w;
const dx=b.x-a.x, minDx=(wa+wb)/2;
if(Math.abs(dx)<minDx){
const overlap=minDx-Math.abs(dx), dir=dx>=0?1:-1;
const ima=1/a.m, imb=1/b.m, tot=ima+imb;
a.x-=dir*overlap*ima/tot; b.x+=dir*overlap*imb/tot;
const rel=b.vx-a.vx;
if(rel*dir<0){
const e=0.25, va=a.vx, vb=b.vx;
a.vx=(a.m*va+b.m*vb+b.m*e*(vb-va))/(a.m+b.m);
b.vx=(a.m*va+b.m*vb+a.m*e*(va-vb))/(a.m+b.m);
}
}
}else if(!a.grounded||!b.grounded){
const dx=b.x-a.x, dy=b.y-a.y, dist=Math.hypot(dx,dy);
const ra=0.5*Math.hypot(a.w,a.h)*0.7, rb=0.5*Math.hypot(b.w,b.h)*0.7;
if(dist>0&&dist<ra+rb){
const air=!a.grounded?a:b, oth=air===a?b:a;
air.vx=oth.vx*0.5+air.vx*0.5; air.vy=Math.abs(air.vy)*0.3; air.omega*=0.5;
air.x+=dx/dist*0.02;
}
}
}
if(yanked&&!stopped){
const clothDone=clothX>TABLE_W||!clothActive;
const allStill=objects.every(o=>o.grounded&&Math.abs(o.vx)<0.03&&Math.abs(o.omega)<0.03&&Math.abs(o.vy)<0.03);
if(clothDone&&allStill) stillTime+=dt; else stillTime=0;
if(stillTime>0.6||(pullSpeed<0.01&&allStill&&stillTime>0.6)){
stopped=true;
const onTable=objects.filter(o=>o.grounded&&o.surfaceY===TABLE_Y).length;
const upright=objects.filter(o=>o.grounded&&o.surfaceY===TABLE_Y&&!o.fallen&&Math.abs(o.theta)<0.15).length;
statusEl.textContent='Stopped. On table: '+onTable+' of '+objects.length+'. Upright on table: '+upright+' of '+onTable+'.';
}
}
}
const SCALE=62, OX=80, OY=330;
function sx(x){return OX+x*SCALE} function sy(y){return OY-y*SCALE}
function draw(){
ctx.clearRect(0,0,1000,560);
const grad=ctx.createLinearGradient(0,0,0,560); grad.addColorStop(0,'#f7e8c9'); grad.addColorStop(1,'#e8d5b5');
ctx.fillStyle=grad; ctx.fillRect(0,0,1000,560);
ctx.fillStyle='#b08968'; ctx.fillRect(0,sy(FLOOR_Y),1000,560-sy(FLOOR_Y));
ctx.strokeStyle='rgba(0,0,0,0.15)'; for(let i=0;i<10;i++){ctx.beginPath();ctx.moveTo(i*110,sy(FLOOR_Y));ctx.lineTo(i*110+40,560);ctx.stroke();}
ctx.fillStyle='#fff8e7'; ctx.fillRect(60,40,180,140); ctx.strokeStyle='#8b5a2b'; ctx.strokeRect(60,40,180,140);
ctx.fillStyle='#a8d8ea'; ctx.fillRect(70,50,160,120);
ctx.fillStyle='#8b5a2b'; ctx.fillRect(sx(0),sy(0),TABLE_W*SCALE,0.28*SCALE);
ctx.fillStyle='#6e4420'; ctx.fillRect(sx(0.4),sy(0)-0.28*SCALE,0.3*SCALE,(2.5-0.28)*SCALE); ctx.fillRect(sx(11.3),sy(0)-0.28*SCALE,0.3*SCALE,(2.5-0.28)*SCALE);
if(clothX<16){
ctx.fillStyle='#d64545'; ctx.fillRect(sx(clothX),sy(0.07),CLOTH_LEN*SCALE,0.07*SCALE);
ctx.fillStyle='rgba(255,255,255,0.7)';
for(let x=clothX;x<clothX+CLOTH_LEN;x+=0.6) ctx.fillRect(sx(x),sy(0.07),0.08*SCALE,0.07*SCALE);
}
for(const o of objects){
const landY=o.grounded?o.surfaceY:((o.x>=0&&o.x<=TABLE_W)?TABLE_Y:FLOOR_Y);
ctx.fillStyle='rgba(0,0,0,0.18)'; ctx.beginPath(); ctx.ellipse(sx(o.x),sy(landY)+4,(o.fallen?o.h:o.w)/2*SCALE,6,0,0,Math.PI*2); ctx.fill();
}
for(const o of objects){
ctx.save(); ctx.translate(sx(o.x),sy(o.y)); ctx.rotate(-o.theta);
const w=o.w*SCALE, h=o.h*SCALE;
if(o.type==='plate'){
ctx.fillStyle='#fff'; ctx.strokeStyle='#2a5a8a'; ctx.lineWidth=2;
ctx.beginPath(); ctx.ellipse(0,0,w/2,h/2+4,0,0,Math.PI*2); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.ellipse(0,0,w/3,h/3+2,0,0,Math.PI*2); ctx.stroke();
}else if(o.type==='glass'){
ctx.fillStyle='rgba(168,216,234,0.6)'; ctx.strokeStyle='#2a5a8a'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(-w/2,-h/2); ctx.lineTo(w/2,-h/2); ctx.lineTo(w*0.35,h/2); ctx.lineTo(-w*0.35,h/2); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle='#e67e22'; ctx.fillRect(-w*0.38,0,w*0.76,h*0.4);
}else if(o.type==='bowl'){
ctx.fillStyle='#e67e22'; ctx.strokeStyle='#7e3a0a'; ctx.lineWidth=2;
ctx.beginPath(); ctx.ellipse(0,-h*0.2,w/2,h*0.25,0,0,Math.PI*2); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-w/2,-h*0.2); ctx.quadraticCurveTo(0,h*0.7,w/2,-h*0.2); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle='#f39c12'; ctx.beginPath(); ctx.ellipse(0,-h*0.2,w*0.4,h*0.15,0,0,Math.PI*2); ctx.fill();
}else if(o.type==='candle'){
ctx.fillStyle='#f1c40f'; ctx.strokeStyle='#7a6200'; ctx.lineWidth=2;
ctx.beginPath(); ctx.ellipse(0,h/2,w/2,6,0,0,Math.PI*2); ctx.fill(); ctx.stroke();
ctx.fillRect(-3,-h/2+10,6,h-16);
ctx.fillStyle='#fff'; ctx.fillRect(-w*0.25,-h/2,w*0.5,h*0.35);
const fl=6+Math.sin(simTime*12+o.x)*2;
ctx.fillStyle='#ff8c00'; ctx.beginPath(); ctx.moveTo(0,-h/2-fl); ctx.quadraticCurveTo(6,-h/2,0,-h/2+4); ctx.quadraticCurveTo(-6,-h/2,0,-h/2-fl); ctx.fill();
}else if(o.type==='cup'){
ctx.fillStyle='#c0392b'; ctx.strokeStyle='#5e1a12'; ctx.lineWidth=2;
ctx.fillRect(-w/2,-h/2,w,h); ctx.strokeRect(-w/2,-h/2,w,h);
ctx.beginPath(); ctx.arc(w/2,0,8,0,Math.PI*2); ctx.stroke();
ctx.fillStyle='#fff'; ctx.fillRect(-w/2,-h/2,w,h*0.2);
}
ctx.restore();
}
}
let last=0, acc=0;
function frame(t){
requestAnimationFrame(frame);
if(!last) last=t; let dt=(t-last)/1000; last=t; if(dt>0.05) dt=0.05; acc+=dt;
const h=1/240;
while(acc>=h){step(h); acc-=h;}
draw();
}
reset();
(function(){
const m=location.hash.match(/speed=([0-9]*\.?[0-9]+)/);
if(m){setSpeed(parseFloat(m[1])); setTimeout(yank,1000);}
})();
requestAnimationFrame(frame);
</script>
</body>
</html>
Where it behaves like the trick: cloth speed is prescribed, everything else is friction and inertia — kinetic friction $\mu_c m g$ accelerates objects while cloth is under them, static capture makes them ride a slow cloth, table friction $\mu_t=0.25$ decelerates them after the cloth passes, $ \Delta v=\mu_c g L/v$ and $s=\Delta v^2/(2\mu_t g)$ emerge from integration, not from a speed-keyed rule, so slow pulls drag, fast pulls leave, and intermediate pulls slide, tip the candlestick and right glass (the only two with $\mu_c>w/h$, $0.335>0.327$ and $0.300>0.295$), and drop objects off the right edge, with grounded horizontal collisions (restitution $0.25$) and floor sliding.
Where it does not, and how it fails: tipping torque while cloth is in contact is frozen at its $\theta=0$ value ($N w/2$ at the edge) instead of being updated with $\theta$, so there is no gravity runaway during contact; after contact the full $r_x,r_y$ geometry, viscous damping $1.2,{\rm s^{-1}}$, flat-impact restitution $0.35$, and a clamp at $\pm\pi/2$ (fallen on side, then slides on its side, no rolling) take over, so actual tipping thresholds are $1$–$2,{\rm ft/s}$ off the prediction and a $12,{\rm ft/s}$ pull can still rock the candlestick noticeably before it recovers. Objects that cross the table edge become airborne flat with no edge-pivot angular velocity, the cloth does not fall or wrinkle and simply slides off canvas, normal force is taken as $mg$ with no vertical acceleration during pivoting, landing only damps $v_x$ by $0.75$ and $\omega$ by $0.5$, airborne collisions are a crude circle push with no collision-induced tipping, and very slow pulls take real time to drag the cloth off — at $1,{\rm ft/s}$ the simulation runs $\sim12,{\rm s}$ and dropped objects land flat on the floor and may slide into each other, which is the toy's version of the classic failure.