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.
Using a table length of 2.0 m, a cloth overhang of 0.5 m on the left, and a table friction coefficient μ_t = 0.6, the threshold speed for an object to stay on the table is the maximum of two conditions: (1) it must slip on the cloth rather than move with it, requiring v > √(μ_c g (x₀ + 0.5)); and (2) its total sliding distance (while on the cloth, then decelerating on the table) must be less than the distance to the right edge, requiring v > √(0.5 μ_c g (x₀ + 0.5)² (1 + μ_c/μ_t) / (2.0 − x₀)). Mass cancels out in this sliding model. For the easiest object to keep (Plate 1: μ_c = 0.35, x₀ = 0.3 m), the slip condition gives 1.66 m/s = 5.44 ft/s, and the displacement condition gives 1.01 m/s = 3.32 ft/s, so it stays above 5.44 ft/s. For the hardest object (Candlestick: μ_c = 0.35, x₀ = 1.7 m), the slip condition gives 2.75 m/s = 9.02 ft/s, and the displacement condition gives 6.62 m/s = 21.73 ft/s, so it stays above 21.73 ft/s. Therefore, the lowest pull speed at which every object stays on the table is 21.7 ft/s, and the speed below which no object stays on the table is 5.4 ft/s. (The 3.0 m cloth length is long enough to be pulled completely off the 2.0 m table for all speeds considered.)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Tablecloth Trick</title>
<style>
body { margin: 0; background: #222; color: #eee; font-family: sans-serif; display: flex; flex-direction: column; align-items: center; }
canvas { background: #333; border: 2px solid #555; margin-top: 10px; }
.controls { margin: 10px; display: flex; gap: 20px; align-items: center; }
.controls label { font-size: 18px; }
.controls input[type=range] { width: 200px; }
.controls button { font-size: 18px; padding: 5px 15px; cursor: pointer; }
.counts { font-size: 20px; margin: 10px; }
.speed-value { font-weight: bold; color: #ff0; }
</style>
</head>
<body>
<div class="controls">
<label>Pull speed: <span id="speedDisplay" class="speed-value">10.0</span> ft/s</label>
<input type="range" id="speedSlider" min="0" max="30" step="0.5" value="10">
<button id="yankBtn">Yank!</button>
<button id="resetBtn">Reset</button>
</div>
<div class="counts" id="counts">On table: 6 / 6, Upright: 6 / 6</div>
<canvas id="canvas" width="800" height="400"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const speedSlider = document.getElementById('speedSlider');
const speedDisplay = document.getElementById('speedDisplay');
const yankBtn = document.getElementById('yankBtn');
const resetBtn = document.getElementById('resetBtn');
const countsDiv = document.getElementById('counts');
const scale = 200;
const offsetX = 100;
const offsetY = 250;
const tableMinX = 0;
const tableMaxX = 2.0;
const floorY = -0.5;
const clothLength = 3.0;
const clothInitialLeft = -0.5;
const g = 9.81;
const fixedDt = 1/120;
let clothX = clothInitialLeft;
let clothV = 0;
let pulling = false;
let simulationActive = false;
let simTime = 0;
let lastTime = 0;
let accumulator = 0;
let objects = [];
function createObjects() {
return [
{ type: 'plate', mass: 0.4, width: 0.25, height: 0.03, mu_c: 0.35, mu_t: 0.6, x: 0.3, y: 0, vx: 0, vy: 0, angle: 0, angular_velocity: 0, state: 'upright', color: '#e0e0e0' },
{ type: 'plate', mass: 0.3, width: 0.22, height: 0.03, mu_c: 0.35, mu_t: 0.6, x: 0.6, y: 0, vx: 0, vy: 0, angle: 0, angular_velocity: 0, state: 'upright', color: '#e0e0e0' },
{ type: 'glass', mass: 0.2, width: 0.07, height: 0.15, mu_c: 0.2, mu_t: 0.6, x: 0.9, y: 0, vx: 0, vy: 0, angle: 0, angular_velocity: 0, state: 'upright', color: '#a0d0ff' },
{ type: 'glass', mass: 0.2, width: 0.07, height: 0.15, mu_c: 0.2, mu_t: 0.6, x: 1.2, y: 0, vx: 0, vy: 0, angle: 0, angular_velocity: 0, state: 'upright', color: '#a0d0ff' },
{ type: 'bowl', mass: 0.6, width: 0.2, height: 0.1, mu_c: 0.4, mu_t: 0.6, x: 1.5, y: 0, vx: 0, vy: 0, angle: 0, angular_velocity: 0, state: 'upright', color: '#ffcc80' },
{ type: 'candlestick', mass: 0.5, width: 0.08, height: 0.25, mu_c: 0.35, mu_t: 0.6, x: 1.7, y: 0, vx: 0, vy: 0, angle: 0, angular_velocity: 0, state: 'upright', color: '#ffd700' }
];
}
function resetSimulation() {
objects = createObjects();
clothX = clothInitialLeft;
clothV = 0;
pulling = false;
simulationActive = false;
simTime = 0;
accumulator = 0;
updateCounts();
}
function startPull() {
resetSimulation();
const speedFt = parseFloat(speedSlider.value);
clothV = speedFt * 0.3048;
pulling = true;
simulationActive = true;
simTime = 0;
lastTime = performance.now();
accumulator = 0;
}
function updatePhysics(dt) {
if (pulling) {
clothX += clothV * dt;
}
for (let obj of objects) {
if (obj.state === 'off') continue;
if (obj.state === 'falling') {
obj.vy -= g * dt;
obj.y += obj.vy * dt;
obj.x += obj.vx * dt;
if (obj.y <= floorY) {
obj.y = floorY;
obj.vy = 0;
obj.vx *= 0.5;
obj.state = 'off';
}
continue;
}
let onCloth = (obj.x > clothX) && (obj.x < tableMaxX);
let mu = onCloth ? obj.mu_c : obj.mu_t;
let surfaceV = onCloth ? clothV : 0;
let vRel = obj.vx - surfaceV;
let aFric = 0;
if (Math.abs(vRel) > 0.01) {
aFric = -mu * g * Math.sign(vRel);
obj.vx += aFric * dt;
} else {
let requiredA = (surfaceV - obj.vx) / dt;
if (Math.abs(requiredA) <= mu * g) {
obj.vx = surfaceV;
} else {
aFric = mu * g * Math.sign(surfaceV - obj.vx);
obj.vx += aFric * dt;
}
}
if (obj.state === 'upright' && Math.abs(aFric) > g * (obj.width / obj.height)) {
obj.state = 'tipping';
obj.angular_velocity = -Math.sign(aFric) * 3.0;
}
obj.x += obj.vx * dt;
if (obj.x < tableMinX || obj.x > tableMaxX) {
obj.state = 'falling';
obj.vy = 0;
}
if (obj.state === 'tipping') {
obj.angle += obj.angular_velocity * dt;
if (Math.abs(obj.angle) >= Math.PI / 2) {
obj.state = 'fallen';
obj.angle = Math.sign(obj.angle) * Math.PI / 2;
let temp = obj.width;
obj.width = obj.height;
obj.height = temp;
obj.mu_c += 0.1;
obj.mu_t += 0.1;
obj.angular_velocity = 0;
}
}
}
for (let i = 0; i < objects.length; i++) {
for (let j = i + 1; j < objects.length; j++) {
let a = objects[i];
let b = objects[j];
if (a.state === 'off' || a.state === 'falling' || b.state === 'off' || b.state === 'falling') continue;
if (a.y !== 0 || b.y !== 0) continue;
let minDist = (a.width + b.width) / 2;
let dx = a.x - b.x;
if (Math.abs(dx) < minDist) {
let m1 = a.mass, m2 = b.mass;
let v1 = a.vx, v2 = b.vx;
let e = 0.3;
let v1_new = ((m1 - e * m2) * v1 + (1 + e) * m2 * v2) / (m1 + m2);
let v2_new = ((m2 - e * m1) * v2 + (1 + e) * m1 * v1) / (m1 + m2);
a.vx = v1_new;
b.vx = v2_new;
let overlap = minDist - Math.abs(dx);
let sign = Math.sign(dx);
a.x += sign * overlap * 0.5;
b.x -= sign * overlap * 0.5;
let dv1 = Math.abs(v1_new - v1);
let dv2 = Math.abs(v2_new - v2);
if (a.state === 'upright' && dv1 / dt > g * (a.width / a.height)) {
a.state = 'tipping';
a.angular_velocity = -Math.sign(v1_new - v1) * 3.0;
}
if (b.state === 'upright' && dv2 / dt > g * (b.width / b.height)) {
b.state = 'tipping';
b.angular_velocity = -Math.sign(v2_new - v2) * 3.0;
}
}
}
}
}
function updateCounts() {
let onTable = 0;
let upright = 0;
for (let obj of objects) {
if (obj.state !== 'off' && obj.state !== 'falling' && obj.x >= tableMinX && obj.x <= tableMaxX && obj.y === 0) {
onTable++;
if (obj.state === 'upright' && Math.abs(obj.angle) < 0.1) {
upright++;
}
}
}
countsDiv.textContent = `On table: ${onTable} / 6, Upright: ${upright} / 6`;
}
function worldToCanvasX(x) {
return offsetX + (x + 0.5) * scale;
}
function worldToCanvasY(y) {
return offsetY - y * scale;
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#2a2a2a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#4a3a2a';
ctx.fillRect(0, worldToCanvasY(floorY), canvas.width, canvas.height - worldToCanvasY(floorY));
ctx.strokeStyle = '#6a5a4a';
ctx.beginPath();
ctx.moveTo(0, worldToCanvasY(floorY));
ctx.lineTo(canvas.width, worldToCanvasY(floorY));
ctx.stroke();
ctx.fillStyle = '#8B5A2B';
ctx.fillRect(worldToCanvasX(0), worldToCanvasY(0), (tableMaxX - tableMinX) * scale, 40);
ctx.fillStyle = '#6B3A1B';
ctx.fillRect(worldToCanvasX(0.2), worldToCanvasY(0) + 40, 20, 80);
ctx.fillRect(worldToCanvasX(1.8) - 20, worldToCanvasY(0) + 40, 20, 80);
let clothLeftCanvas = worldToCanvasX(clothX);
let clothRightCanvas = worldToCanvasX(clothX + clothLength);
let clothTopCanvas = worldToCanvasY(0.02);
let clothBottomCanvas = worldToCanvasY(0);
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(clothLeftCanvas, clothTopCanvas, clothRightCanvas - clothLeftCanvas, clothBottomCanvas - clothTopCanvas);
if (clothX + clothLength > tableMaxX) {
let hangLeft = worldToCanvasX(tableMaxX);
let hangRight = clothRightCanvas;
let hangTop = worldToCanvasY(0);
let hangBottom = worldToCanvasY(-0.3);
ctx.fillStyle = '#e0e0e0';
ctx.fillRect(hangLeft, hangTop, hangRight - hangLeft, hangBottom - hangTop);
}
ctx.strokeStyle = '#d0d0d0';
ctx.lineWidth = 1;
for (let x = clothLeftCanvas; x < clothRightCanvas; x += 20) {
ctx.beginPath();
ctx.moveTo(x, clothTopCanvas);
ctx.lineTo(x, clothBottomCanvas);
ctx.stroke();
}
for (let obj of objects) {
if (obj.state === 'off' && obj.y < floorY) continue;
ctx.save();
let cx = worldToCanvasX(obj.x);
let cy = worldToCanvasY(obj.y);
ctx.translate(cx, cy);
ctx.rotate(obj.angle);
let w = obj.width * scale;
let h = obj.height * scale;
ctx.fillStyle = obj.color;
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
if (obj.type === 'plate') {
ctx.beginPath();
ctx.ellipse(0, -h/2, w/2, h/2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else if (obj.type === 'glass') {
ctx.beginPath();
ctx.moveTo(-w/2, 0);
ctx.lineTo(-w/2, -h*0.6);
ctx.quadraticCurveTo(0, -h*0.8, w/2, -h*0.6);
ctx.lineTo(w/2, 0);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#c0e0ff';
ctx.fillRect(-w*0.1, -h*0.6, w*0.2, h*0.4);
ctx.beginPath();
ctx.ellipse(0, 0, w*0.6, h*0.05, 0, 0, Math.PI*2);
ctx.fill();
ctx.stroke();
} else if (obj.type === 'candlestick') {
ctx.fillRect(-w/2, -h, w, h);
ctx.strokeRect(-w/2, -h, w, h);
ctx.fillStyle = '#ffaa00';
ctx.beginPath();
ctx.moveTo(0, -h - 10);
ctx.lineTo(-w/2, -h);
ctx.lineTo(w/2, -h);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#ffff00';
ctx.beginPath();
ctx.ellipse(0, -h - 5, w*0.2, 5, 0, 0, Math.PI*2);
ctx.fill();
} else if (obj.type === 'bowl') {
ctx.beginPath();
ctx.moveTo(-w/2, 0);
ctx.lineTo(-w/2, -h*0.5);
ctx.quadraticCurveTo(0, -h*1.2, w/2, -h*0.5);
ctx.lineTo(w/2, 0);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
}
function animate(now) {
if (simulationActive) {
if (lastTime === 0) lastTime = now;
let dtReal = (now - lastTime) / 1000;
lastTime = now;
if (dtReal > 0.1) dtReal = 0.1;
accumulator += dtReal;
while (accumulator >= fixedDt) {
updatePhysics(fixedDt);
simTime += fixedDt;
accumulator -= fixedDt;
if (simTime >= 5.0) {
simulationActive = false;
pulling = false;
clothV = 0;
break;
}
}
updateCounts();
}
draw();
requestAnimationFrame(animate);
}
speedSlider.addEventListener('input', () => {
speedDisplay.textContent = parseFloat(speedSlider.value).toFixed(1);
});
yankBtn.addEventListener('click', () => {
startPull();
});
resetBtn.addEventListener('click', () => {
resetSimulation();
draw();
});
function handleHash() {
const hash = window.location.hash;
const match = hash.match(/^#speed=([\d.]+)$/);
if (match) {
const speed = parseFloat(match[1]);
if (!isNaN(speed)) {
speedSlider.value = speed;
speedDisplay.textContent = speed.toFixed(1);
setTimeout(() => {
startPull();
}, 1000);
}
}
}
resetSimulation();
requestAnimationFrame(animate);
window.addEventListener('hashchange', handleHash);
handleHash();
</script>
</body>
</html>
The simulation captures the essential physics of the tablecloth trick: objects on the cloth experience kinetic and static friction from the moving cloth, and once the cloth’s left edge passes them they switch to table friction. Slow pulls drag objects with the cloth and off the right edge; fast pulls let the cloth slip out, leaving objects nearly stationary. The thresholds roughly match the prediction: below about 5.4 ft/s everything is dragged off, and above about 21.7 ft/s the sliding model says all objects stay. However, the simulation adds tipping and collisions, which the prediction ignores. The candlestick (μ_c = 0.35, width/height = 0.32) exceeds its critical tipping acceleration (μ_c g > g w/h) and tips over almost immediately when it starts sliding, often falling off even at speeds where the point-mass model predicts it should stay. The glasses and plates do not tip from friction alone, but collisions can knock them over. The bowl is heavy and has high friction, so it is the hardest to keep on the table; it often slides off at moderate speeds. The cloth is a rigid moving surface, not a deformable sheet, and collisions are resolved as 1D elastic impacts with restitution 0.3. The simulation freezes after 5 seconds, so any object still falling will freeze mid-air (though most hit the floor quickly). The counts update live and freeze at the end. Loading with #speed=12 sets the slider to 12 ft/s and automatically pulls after one second. The main failure mode you’ll see before the physics says it should fail: the candlestick tips and rolls off at almost any speed, and the bowl slides off at surprisingly low speeds because it starts near the right edge and has high cloth friction.