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.
For a nonzero pull, I predict 23.0 ft/s as the first clean setting where everything stays on the table, and 4.5 ft/s as the speed below which nothing stays. The 8.45-ft cloth produces extraction distances (d=1.31,2.06,3.31,4.24,5.43,6.22,7.59) ft. In that order, the masses are 0.65, 0.32, 0.85, 1.40, 0.60, 0.38 and 1.20 kg—mass cancels during free sliding but matters in collisions—and the cloth friction coefficients are 0.24, 0.16, 0.28, 0.34, 0.23, 0.17 and 0.26, with table μ=0.30 and (g=32.2\ \text{ft/s}^2). An object rides the cloth off the table when (v<\sqrt{2\mu gd}); the smallest of those seven limits is 4.50 ft/s. For the last bowl, with a 1.00-ft safety margin, (u_{\max}=\sqrt{2gM/(1/0.26+1/0.30)}=3.00) ft/s, giving (v=(0.26\cdot32.2\cdot7.59)/u_{\max}+u_{\max}/2=22.7) ft/s; I rounded up to 23.0 to allow for numerical integration.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>The Great Tablecloth Escape</title>
<style>
:root{
color-scheme:light;
--ink:#26322f;
--muted:#6c736c;
--paper:#fffaf0;
--red:#a43b32;
--red-dark:#762820;
--gold:#d59b45;
--line:#ded4c2;
}
*{box-sizing:border-box}
body{
margin:0;
background:
radial-gradient(circle at 15% 0%,#35443e 0,transparent 38rem),
#202c29;
color:var(--ink);
font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
}
.wrap{max-width:1180px;margin:0 auto;padding:30px 18px 42px}
header{color:#fff8e9;margin:0 4px 20px;display:flex;align-items:end;justify-content:space-between;gap:20px}
h1{font-family:Georgia,"Times New Roman",serif;font-weight:500;font-size:clamp(2rem,5vw,3.6rem);line-height:.98;margin:0;letter-spacing:-.035em}
header p{margin:0 0 4px;max-width:330px;color:#d8ded7;line-height:1.45;font-size:.94rem}
.toy{background:var(--paper);border:1px solid #ffffff38;border-radius:24px;overflow:hidden;box-shadow:0 24px 80px #0005}
.stage{position:relative;background:#ead7ba}
canvas{display:block;width:100%;height:auto;aspect-ratio:1120/640}
.view-tag{
position:absolute;left:16px;top:15px;padding:6px 9px;border-radius:7px;
background:#fff9;border:1px solid #fff8;color:#665d50;
font-size:.66rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase;
pointer-events:none;
}
.controls{padding:20px 22px 16px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:18px 24px;border-bottom:1px solid var(--line)}
.speed-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:10px}
.speed-head label{font-size:.75rem;font-weight:850;letter-spacing:.11em;text-transform:uppercase;color:#6c675d}
output{font-family:Georgia,"Times New Roman",serif;font-size:1.65rem;color:var(--red-dark);font-variant-numeric:tabular-nums}
.slider-line{display:flex;align-items:center;gap:13px}
input[type="range"]{width:100%;accent-color:var(--red);cursor:pointer}
.number{
display:flex;align-items:center;gap:6px;border:1px solid #cfc5b5;border-radius:9px;
padding:7px 9px;background:#fff;min-width:112px;
}
.number input{width:62px;border:0;outline:0;background:transparent;color:var(--ink);font:inherit;font-weight:750;font-variant-numeric:tabular-nums}
.number span{font-size:.72rem;color:var(--muted)}
.markers{display:flex;justify-content:space-between;margin-top:5px;color:#8a8378;font-size:.67rem}
.buttons{display:flex;gap:9px;align-items:center}
button{
border:0;border-radius:11px;padding:13px 17px;font:inherit;font-weight:800;cursor:pointer;
transition:transform .12s ease,background .12s ease;white-space:nowrap;
}
button:hover:not(:disabled){transform:translateY(-1px)}
button:focus-visible,input:focus-visible,summary:focus-visible{outline:3px solid #e2a04d;outline-offset:3px}
button:disabled{opacity:.55;cursor:wait}
#yank{background:var(--red);color:#fff;box-shadow:0 5px 14px #a43b3230}
#yank:hover:not(:disabled){background:#8e3028}
#reset{background:#eee7da;color:#514c44}
.readout{padding:15px 22px 18px;display:flex;align-items:center;gap:20px;min-height:67px}
.status-label{display:block;font-size:.65rem;font-weight:850;letter-spacing:.13em;text-transform:uppercase;color:#918778;margin-bottom:3px}
#status{margin:0;font-size:.9rem;line-height:1.4;color:#5c6059}
.result{margin-left:auto;display:flex;align-items:center;gap:18px}
.result[hidden]{display:none}
.count{display:flex;align-items:center;gap:7px}
.count strong{font-family:Georgia,"Times New Roman",serif;font-size:2rem;line-height:1;color:var(--red-dark)}
.count span{font-size:.68rem;line-height:1.15;text-transform:uppercase;letter-spacing:.06em;color:#766e62;max-width:58px}
#fates{flex-basis:100%;margin:8px 0 0;font-size:.77rem;color:#766e62}
.result-wrap{flex:1}
.under{display:grid;grid-template-columns:1fr auto;gap:14px;align-items:start;padding:0 22px 18px}
details{border-top:1px solid var(--line);padding-top:12px}
summary{cursor:pointer;font-weight:750;font-size:.82rem;color:#665f54}
.table-scroll{overflow:auto;margin-top:12px}
table{border-collapse:collapse;width:100%;font-size:.75rem;font-variant-numeric:tabular-nums}
th,td{text-align:left;padding:7px 9px;border-bottom:1px solid #e7dfd2;white-space:nowrap}
th{color:#837a6c;font-size:.65rem;text-transform:uppercase;letter-spacing:.08em}
.fine{font-size:.72rem;color:#837c70;line-height:1.45;margin:10px 0 0}
kbd{font:inherit;font-size:.68rem;border:1px solid #cfc5b5;border-bottom-width:2px;border-radius:4px;padding:1px 5px;background:#fff}
.keys{font-size:.7rem;color:#aaa79e;white-space:nowrap;padding-top:14px}
footer{text-align:center;color:#aeb9b2;font-size:.72rem;padding-top:17px}
@media(max-width:760px){
header{display:block}
header p{margin-top:12px}
.controls{grid-template-columns:1fr;padding:17px}
.buttons{justify-content:stretch}
.buttons button{flex:1}
.readout{align-items:flex-start;flex-direction:column;gap:12px;padding:14px 17px}
.result{margin:0;width:100%}
.under{padding:0 17px 17px;grid-template-columns:1fr}
.keys{display:none}
.view-tag{left:10px;top:10px}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>The Great<br>Tablecloth Escape</h1>
<p>Seven pieces of dinnerware. One continuous cloth. The crockery keeps its own counsel.</p>
</header>
<main class="toy">
<section class="stage" aria-label="Animated side view of a dinner table">
<canvas id="scene" width="1120" height="640">
A table with seven objects on a cloth. Use the controls below to pull the cloth.
</canvas>
<div class="view-tag">Side view · 2-D physics</div>
</section>
<section class="controls" aria-label="Tablecloth controls">
<div>
<div class="speed-head">
<label for="speed">Pull speed</label>
<output id="speedOut" for="speed speedNumber">12.0 ft/s</output>
</div>
<div class="slider-line">
<input id="speed" type="range" min="0.5" max="30" step="0.1" value="12">
<label class="number" aria-label="Exact pull speed">
<input id="speedNumber" type="number" min="0" step="0.1" value="12">
<span>ft/s</span>
</label>
</div>
<div class="markers" aria-hidden="true">
<span>drags along</span><span>mixed results</span><span>clean-ish</span>
</div>
</div>
<div class="buttons">
<button id="yank" type="button">Yank the cloth</button>
<button id="reset" type="button">Reset table</button>
</div>
</section>
<section class="readout">
<div class="result-wrap">
<span class="status-label">Status</span>
<p id="status" role="status" aria-live="polite">Ready.</p>
<p id="fates"></p>
</div>
<div id="result" class="result" hidden>
<div class="count"><strong id="onCount">0</strong><span>on the table</span></div>
<div class="count"><strong id="upCount">0</strong><span>of those upright</span></div>
</div>
</section>
<div class="under">
<details>
<summary>Inspect the seven objects and their friction</summary>
<div class="table-scroll">
<table>
<thead><tr><th>Object</th><th>Mass</th><th>Cloth contact</th><th>μ cloth</th></tr></thead>
<tbody id="specs"></tbody>
</table>
</div>
<p class="fine">
Contact area is shown because the objects really are different; Coulomb friction itself is
μN, not an area-proportional force. Base width also controls rocking. Bare-table μ is 0.30.
</p>
</details>
<div class="keys"><kbd>Space</kbd> yank <kbd>R</kbd> reset</div>
</div>
</main>
<footer>Constant-velocity cloth · dry friction · momentum collisions · no crockery was rendered breakable</footer>
</div>
<script>
(() => {
"use strict";
const G = 32.2; // ft / s²
const TABLE = { l: 0.55, r: 8.45, h: 2.55 };
const CLOTH = { x0: 0.30, length: 8.45 };
const FLOOR_MU = 0.48;
const DEFS = [
{name:"Dinner plate", kind:"plate", x:1.20, w:.82, h:.16, hc:.07, m:.65, area:.53, muC:.24, muT:.30, color:"#f3ead8", accent:"#557a83"},
{name:"Water glass", kind:"glass", x:2.20, w:.32, h:.66, hc:.31, m:.32, area:.08, muC:.16, muT:.30, color:"#b9dadd", accent:"#6c9ea5"},
{name:"Soup bowl", kind:"bowl", x:3.25, w:.72, h:.43, hc:.18, m:.85, area:.32, muC:.28, muT:.30, color:"#d7e0c2", accent:"#6f8060"},
{name:"Candlestick", kind:"candle", x:4.35, w:.38, h:1.35, hc:.68, m:1.40, area:.11, muC:.34, muT:.30, color:"#c89b4b", accent:"#815c2b"},
{name:"Salad plate", kind:"plate", x:5.35, w:.76, h:.15, hc:.065, m:.60, area:.45, muC:.23, muT:.30, color:"#e7cfc0", accent:"#9c5d4b"},
{name:"Tall glass", kind:"glass", x:6.35, w:.34, h:.72, hc:.34, m:.38, area:.09, muC:.17, muT:.30, color:"#c4dde0", accent:"#6c9ea5"},
{name:"Serving bowl", kind:"serving", x:7.45, w:.88, h:.50, hc:.21, m:1.20, area:.54, muC:.26, muT:.30, color:"#b95043", accent:"#77362f"}
];
const canvas = document.getElementById("scene");
const ctx = canvas.getContext("2d");
const W = 1120, H = 640;
const S = 88, X0 = 80, TABLE_Y = 340;
const FLOOR_Y = TABLE_Y + TABLE.h * S;
let DPR = 1;
const speedRange = document.getElementById("speed");
const speedNumber = document.getElementById("speedNumber");
const speedOut = document.getElementById("speedOut");
const yankButton = document.getElementById("yank");
const resetButton = document.getElementById("reset");
const statusEl = document.getElementById("status");
const resultEl = document.getElementById("result");
const fatesEl = document.getElementById("fates");
const onCountEl = document.getElementById("onCount");
const upCountEl = document.getElementById("upCount");
let speed = 12;
let objects = [];
let cLeft = CLOTH.x0;
let cRight = cLeft + CLOTH.length;
let pulled = 0;
let running = false;
let finished = false;
let hasPulled = false;
let clothActive = false;
let clothDone = false;
let simTime = 0;
let accumulator = 0;
let quietTime = 0;
let lastCollision = -100;
const X = x => X0 + x * S;
const inertiaPerMass = o => (o.w * o.w + o.h * o.h) / 3;
const normAngle = a => Math.atan2(Math.sin(a), Math.cos(a));
function boundWidth(o, angle = o.angle) {
return o.w * Math.abs(Math.cos(angle)) + o.h * Math.abs(Math.sin(angle));
}
function boundHeight(o, angle = o.angle) {
return o.h * Math.abs(Math.cos(angle)) + o.w * Math.abs(Math.sin(angle));
}
function supportHeight(o) {
if (!o.surface) return;
const base = o.surface === "floor" ? -TABLE.h : 0;
o.z = base + boundHeight(o) / 2;
}
function makeObject(d, id) {
return Object.assign({}, d, {
id, vx:0, vz:0, z:d.h/2, angle:0, omega:0, ax:0,
surface:"table", rocking:false, tiltDir:0, tipped:false,
bounces:0, lit:d.kind === "candle"
});
}
function formatSpeed(v) {
if (v >= 100) return String(Math.round(v * 10) / 10);
return v.toFixed(v < 10 ? 2 : 1);
}
function setSpeed(v) {
if (!Number.isFinite(v)) return;
speed = Math.abs(v); // Speed is a magnitude; negative hash values reverse no physics.
speedOut.textContent = formatSpeed(speed) + " ft/s";
speedNumber.value = String(Math.round(speed * 1000) / 1000);
speedRange.value = String(Math.max(.5, Math.min(30, speed || .5)));
}
function resetTable() {
objects = DEFS.map(makeObject);
cLeft = CLOTH.x0;
cRight = cLeft + CLOTH.length;
pulled = 0;
running = false;
finished = false;
hasPulled = false;
clothActive = false;
clothDone = false;
simTime = 0;
accumulator = 0;
quietTime = 0;
lastCollision = -100;
resultEl.hidden = true;
fatesEl.textContent = "";
yankButton.textContent = "Yank the cloth";
yankButton.disabled = false;
speedRange.disabled = false;
speedNumber.disabled = false;
statusEl.textContent = "Ready · seven objects, one 8.45-ft cloth.";
}
function yank() {
if (running) return;
if (finished || hasPulled) resetTable();
hasPulled = true;
finished = false;
running = true;
clothActive = speed > 0;
clothDone = speed === 0;
pulled = 0;
quietTime = 0;
accumulator = 0;
resultEl.hidden = true;
fatesEl.textContent = "";
yankButton.disabled = true;
speedRange.disabled = true;
speedNumber.disabled = true;
statusEl.textContent = speed > 0
? "The cloth is moving…"
: "A zero-speed yank makes no demand on friction.";
if (speed === 0) finish();
}
function beginFall(o) {
if (o.surface !== "table") return;
const edgeDirection = o.x > TABLE.r ? 1 : -1;
const spinDirection = Math.sign(o.vx) || edgeDirection;
o.surface = null;
o.vz = 0;
o.bounces = 0;
o.rocking = false;
o.lit = false;
// Losing the support under the centre of mass starts an edge-tip rotation.
if (!o.tipped && Math.abs(o.angle) < .03) {
o.omega = spinDirection * (.8 + Math.min(2.2, Math.abs(o.vx) * .22));
} else if (Math.abs(o.omega) < .05) {
o.omega = spinDirection * .35;
}
}
function integrateRock(o, ax, dt) {
if (o.tipped) {
o.omega = 0;
return;
}
const b = o.w / 2;
const I = inertiaPerMass(o);
if (!o.rocking) {
const liftAcceleration = G * b / o.hc;
if (Math.abs(ax) <= liftAcceleration) return;
o.rocking = true;
o.tiltDir = -Math.sign(ax); // The top lags the accelerating base.
o.angle = 0;
o.omega = 0;
}
const s = o.tiltDir;
let phi = Math.max(0, s * o.angle);
let angularVelocity = s * o.omega;
const sn = Math.sin(phi), cs = Math.cos(phi);
// Rotation about the lifted base corner, including gravity and base acceleration.
const alpha = (
G * (o.hc * sn - b * cs) -
s * ax * (b * sn + o.hc * cs)
) / I;
angularVelocity += alpha * dt;
phi += angularVelocity * dt;
if (phi <= 0) {
o.angle = 0;
o.omega = 0;
o.rocking = false;
o.tiltDir = 0;
} else if (phi >= Math.PI / 2) {
o.angle = s * Math.PI / 2;
o.omega = 0;
o.rocking = false;
o.tipped = true;
o.lit = false;
} else {
o.angle = s * phi;
o.omega = s * angularVelocity;
}
}
function integrateSupported(o, dt) {
const oldV = o.vx;
let ax = 0;
const onCloth = o.surface === "table" && clothActive &&
cLeft < o.x + o.w / 2 &&
cRight > o.x - o.w / 2;
if (onCloth) {
const relative = speed - o.vx;
const frictionDv = o.muC * G * dt;
if (Math.abs(relative) <= frictionDv) {
// Static contact: once caught up, it rides at the cloth's velocity.
o.vx = speed;
ax = 0;
} else {
const direction = Math.sign(relative);
o.vx += direction * frictionDv;
ax = direction * o.muC * G;
}
} else {
const mu = o.surface === "floor" ? FLOOR_MU : o.muT;
if (Math.abs(o.vx) > 0) {
const direction = Math.sign(o.vx);
const frictionDv = mu * G * dt;
if (Math.abs(o.vx) <= frictionDv) {
o.vx = 0;
ax = 0;
} else {
o.vx -= direction * frictionDv;
ax = -direction * mu * G;
}
}
}
o.ax = ax;
o.x += (oldV + o.vx) * .5 * dt;
if (o.surface === "table") {
integrateRock(o, ax, dt);
supportHeight(o);
if (o.x > TABLE.r || o.x < TABLE.l) beginFall(o);
} else {
o.omega = 0;
supportHeight(o);
if (Math.abs(o.vx) < 1e-7) o.vx = 0;
}
}
function integrateFall(o, dt) {
o.vz -= G * dt;
o.x += o.vx * dt;
o.z += o.vz * dt;
o.angle += o.omega * dt;
const floorCentre = -TABLE.h + boundHeight(o) / 2;
if (o.z <= floorCentre && o.vz < 0) {
o.z = floorCentre;
const impact = -o.vz;
if (impact > 1.5 && o.bounces < 1) {
o.vz = impact * .16;
o.vx *= .82;
o.omega *= .55;
o.bounces++;
} else {
o.surface = "floor";
o.vz = 0;
// A sleep rule, not a rigid-body stacking solver: settle on a nearby face.
const quantum = o.kind === "plate" ? Math.PI : Math.PI / 2;
o.angle = Math.round(o.angle / quantum) * quantum;
o.omega = 0;
o.rocking = false;
o.tipped = Math.abs(normAngle(o.angle)) > .10;
o.vx *= .70;
if (Math.abs(o.vx) < .025) o.vx = 0;
supportHeight(o);
}
}
}
function collisionRock(o, deltaV) {
if (o.surface !== "table" || o.tipped || Math.abs(deltaV) < .28) return;
const s = -Math.sign(deltaV) || 1;
const kick = Math.min(1.4, .45 * Math.abs(deltaV) / Math.sqrt(inertiaPerMass(o)));
if (!o.rocking) {
o.rocking = true;
o.tiltDir = s;
o.angle = s * .0001;
o.omega = s * kick;
} else if (o.tiltDir === s) {
o.omega += s * kick * .25;
}
}
function resolveCollisions() {
let collided = false;
for (let i = 0; i < objects.length; i++) {
for (let j = i + 1; j < objects.length; j++) {
const a = objects[i], b = objects[j];
const ah = boundHeight(a), bh = boundHeight(b);
if (Math.abs(a.z - b.z) >= (ah + bh) / 2) continue;
let dx = b.x - a.x;
const n = dx >= 0 ? 1 : -1;
if (Math.abs(dx) < 1e-8) dx = .00001 * (j - i);
const overlap = (boundWidth(a) + boundWidth(b)) / 2 - Math.abs(dx);
if (overlap <= 0) continue;
const invA = 1 / a.m, invB = 1 / b.m;
const invSum = invA + invB;
const correction = (overlap + .00005) * .68 / invSum;
a.x -= n * correction * invA;
b.x += n * correction * invB;
if (overlap > .002) {
collided = true;
lastCollision = simTime;
}
const relativeNormalSpeed = (b.vx - a.vx) * n;
if (relativeNormalSpeed < 0) {
const impulse = -(1.10) * relativeNormalSpeed / invSum;
const deltaA = -impulse * n * invA;
const deltaB = impulse * n * invB;
a.vx += deltaA;
b.vx += deltaB;
collisionRock(a, deltaA);
collisionRock(b, deltaB);
lastCollision = simTime;
collided = true;
}
}
}
return collided;
}
function physicsStep(dt) {
simTime += dt;
if (clothActive) {
const travel = speed * dt;
cLeft += travel;
cRight += travel;
pulled += travel;
if (cLeft > TABLE.r + .35) {
clothActive = false;
clothDone = true;
}
}
for (const o of objects) {
if (o.surface) integrateSupported(o, dt);
}
for (const o of objects) {
if (!o.surface) integrateFall(o, dt);
}
resolveCollisions();
for (const o of objects) {
if (o.surface === "table") {
supportHeight(o);
if (o.x > TABLE.r || o.x < TABLE.l) beginFall(o);
}
}
const moving = objects.some(o =>
!o.surface || o.rocking || Math.abs(o.vx) > .001 || Math.abs(o.vz) > .001
);
if (clothDone && !clothActive && !moving && simTime - lastCollision > .15) {
quietTime += dt;
} else {
quietTime = 0;
}
if (clothDone && quietTime > .20) finish();
}
function finish() {
if (finished) return;
running = false;
finished = true;
clothActive = false;
clothDone = true;
for (const o of objects) {
if (o.surface && !o.rocking) o.vx = 0;
}
const survivors = objects.filter(o => o.surface === "table");
const upright = survivors.filter(o =>
!o.tipped && Math.abs(normAngle(o.angle)) < .12
);
const floorNames = objects.filter(o => o.surface === "floor").map(o => o.name);
const tippedNames = survivors.filter(o => !upright.includes(o)).map(o => o.name);
onCountEl.textContent = String(survivors.length);
upCountEl.textContent = String(upright.length);
resultEl.hidden = false;
const fateParts = [];
fateParts.push(floorNames.length ? "Floor: " + floorNames.join(", ") + "." : "Nothing hit the floor.");
if (tippedNames.length) fateParts.push("Tipped on the table: " + tippedNames.join(", ") + ".");
fatesEl.textContent = fateParts.join(" ");
statusEl.textContent = "Everything has settled after " + simTime.toFixed(1) + " s of simulated time.";
yankButton.textContent = "Yank again";
yankButton.disabled = false;
speedRange.disabled = false;
speedNumber.disabled = false;
}
// ---------- Canvas art ----------
function roundedRect(x, y, w, h, r) {
const rr = Math.min(r, Math.abs(w)/2, Math.abs(h)/2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
ctx.lineTo(x + w, y + h - rr);
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
ctx.lineTo(x + rr, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
ctx.lineTo(x, y + rr);
ctx.quadraticCurveTo(x, y, x + rr, y);
ctx.closePath();
}
function drawBackground() {
const wall = ctx.createLinearGradient(0, 0, 0, FLOOR_Y);
wall.addColorStop(0, "#f7ecd9");
wall.addColorStop(1, "#dfc5a4");
ctx.fillStyle = wall;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "#cfb28f";
ctx.fillRect(0, FLOOR_Y - 58, W, 58);
ctx.fillStyle = "#f2dfc4";
ctx.fillRect(0, FLOOR_Y - 62, W, 5);
// A small, severely level picture.
ctx.fillStyle = "#7d6248";
ctx.fillRect(63, 82, 174, 116);
ctx.fillStyle = "#f6e4c9";
ctx.fillRect(70, 89, 160, 102);
ctx.fillStyle = "#a8b3a0";
ctx.fillRect(78, 97, 144, 86);
ctx.fillStyle = "#f4e7ca";
ctx.beginPath();
ctx.arc(150, 130, 23, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#7c6249";
ctx.font = "italic 13px Georgia";
ctx.textAlign = "center";
ctx.fillText("dinner, eventually", 150, 174);
// Wall moulding.
ctx.strokeStyle = "#bda17f55";
ctx.lineWidth = 2;
for (let x = 310; x < W; x += 190) {
ctx.strokeRect(x, FLOOR_Y - 52, 150, 42);
}
const floor = ctx.createLinearGradient(0, FLOOR_Y, 0, H);
floor.addColorStop(0, "#a77b58");
floor.addColorStop(1, "#805b43");
ctx.fillStyle = floor;
ctx.fillRect(0, FLOOR_Y, W, H - FLOOR_Y);
ctx.strokeStyle = "#66463255";
ctx.lineWidth = 2;
for (let x = -80; x < W + 100; x += 145) {
ctx.beginPath();
ctx.moveTo(x, FLOOR_Y);
ctx.lineTo(x + 38, H);
ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(0, FLOOR_Y + 38);
ctx.lineTo(W, FLOOR_Y + 38);
ctx.stroke();
}
function drawTable() {
const l = X(TABLE.l), r = X(TABLE.r);
ctx.fillStyle = "#49352b33";
ctx.beginPath();
ctx.ellipse((l+r)/2, FLOOR_Y + 5, (r-l)*.48, 17, 0, 0, Math.PI*2);
ctx.fill();
const wood = ctx.createLinearGradient(l, 0, r, 0);
wood.addColorStop(0, "#684331");
wood.addColorStop(.5, "#8a5a3c");
wood.addColorStop(1, "#59382c");
ctx.fillStyle = wood;
roundedRect(l + 55, TABLE_Y + 26, 34, FLOOR_Y - TABLE_Y - 26, 6);
ctx.fill();
roundedRect(r - 89, TABLE_Y + 26, 34, FLOOR_Y - TABLE_Y - 26, 6);
ctx.fill();
ctx.fillStyle = "#513429";
roundedRect(l + 20, TABLE_Y + 16, r-l-40, 55, 5);
ctx.fill();
ctx.fillStyle = "#9a6846";
roundedRect(l - 7, TABLE_Y - 1, r-l+14, 22, 7);
ctx.fill();
ctx.fillStyle = "#c18a5b";
ctx.fillRect(l + 4, TABLE_Y + 2, r-l-8, 3);
}
function clothPath(x1, x2) {
const top = TABLE_Y - 6;
ctx.beginPath();
ctx.moveTo(x1, top);
ctx.lineTo(x2, top);
const segments = 24;
for (let i = segments; i >= 0; i--) {
const x = x1 + (x2-x1) * i / segments;
const y = TABLE_Y + 15 + 3 * Math.sin(i * .9 + pulled * 1.4);
ctx.lineTo(x, y);
}
ctx.closePath();
}
function drawClothBand(x1, x2) {
if (x2 - x1 < 2) return;
clothPath(x1, x2);
ctx.fillStyle = "#f4e5c9";
ctx.fill();
ctx.save();
clothPath(x1, x2);
ctx.clip();
ctx.globalAlpha = .18;
ctx.fillStyle = "#a8473d";
const offset = (pulled * S) % 28;
for (let x = x1 - 40 - offset; x < x2 + 30; x += 28) ctx.fillRect(x, TABLE_Y-10, 8, 36);
ctx.fillRect(x1, TABLE_Y + 5, x2-x1, 5);
ctx.restore();
clothPath(x1, x2);
ctx.strokeStyle = "#9d4b4055";
ctx.lineWidth = 1.3;
ctx.stroke();
ctx.strokeStyle = "#fff9";
ctx.beginPath();
ctx.moveTo(x1, TABLE_Y - 5);
ctx.lineTo(x2, TABLE_Y - 5);
ctx.stroke();
}
function drawClothPile() {
const x = X(9.55), y = FLOOR_Y - 2;
ctx.save();
ctx.translate(x, y);
ctx.fillStyle = "#3c2b2430";
ctx.beginPath();
ctx.ellipse(0, 5, 100, 10, 0, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = "#eddcbc";
ctx.strokeStyle = "#a8493f";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(-92, 0);
ctx.bezierCurveTo(-67,-29,-39,8,-15,-15);
ctx.bezierCurveTo(13,-39,35,9,58,-10);
ctx.bezierCurveTo(76,-22,89,-6,96,1);
ctx.bezierCurveTo(49,10,-42,12,-92,0);
ctx.fill();
ctx.globalAlpha = .45;
ctx.beginPath();
ctx.moveTo(-68,-8);ctx.quadraticCurveTo(-42,-20,-20,0);
ctx.moveTo(2,-10);ctx.quadraticCurveTo(27,-24,52,-2);
ctx.stroke();
ctx.restore();
}
function drawCloth() {
if (clothDone && hasPulled && pulled > .01) {
drawClothPile();
return;
}
if (cLeft < TABLE.r + .12) {
const x1 = X(cLeft);
const x2 = X(Math.min(cRight, TABLE.r + .12));
drawClothBand(x1, x2);
}
if (clothActive && speed > 7) {
ctx.save();
ctx.strokeStyle = "#8c493b55";
ctx.lineWidth = 3;
ctx.lineCap = "round";
for (let i = 0; i < 3; i++) {
const x = X(TABLE.r) + 26 + i * 19;
const y = TABLE_Y - 20 - i * 13;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + 30 + i * 8, y);
ctx.stroke();
}
ctx.restore();
}
}
function drawShadows() {
for (const o of objects) {
const width = boundWidth(o) * S;
let y, alpha;
if (o.surface === "table") {
y = TABLE_Y + 2;
alpha = .18;
} else {
y = FLOOR_Y + 3;
const bottom = o.z - boundHeight(o)/2;
const aboveFloor = Math.max(0, bottom + TABLE.h);
alpha = .20 / (1 + aboveFloor);
}
ctx.fillStyle = `rgba(48,36,28,${alpha})`;
ctx.beginPath();
ctx.ellipse(X(o.x), y, Math.max(5, width*.42), 4.5, 0, 0, Math.PI*2);
ctx.fill();
}
}
function drawPlate(o, w, h) {
ctx.fillStyle = o.color;
ctx.strokeStyle = "#594f4755";
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(-w/2, -h*.12);
ctx.quadraticCurveTo(-w*.42, h*.50, 0, h*.50);
ctx.quadraticCurveTo(w*.42, h*.50, w/2, -h*.12);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.strokeStyle = o.accent;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.ellipse(0, -h*.12, w*.43, Math.max(2,h*.25), 0, 0, Math.PI*2);
ctx.stroke();
ctx.strokeStyle = "#fff9";
ctx.beginPath();
ctx.moveTo(-w*.30, h*.18);
ctx.lineTo(w*.25, h*.18);
ctx.stroke();
}
function drawGlass(o, w, h) {
const top = -h/2, bottom = h/2;
ctx.fillStyle = "rgba(183,218,222,.52)";
ctx.strokeStyle = o.accent;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(-w*.46, top);
ctx.lineTo(-w*.34, bottom-2);
ctx.quadraticCurveTo(0,bottom+2,w*.34,bottom-2);
ctx.lineTo(w*.46,top);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#79b2bc55";
ctx.fillRect(-w*.34, bottom-h*.28, w*.68, h*.22);
ctx.strokeStyle = "#e9ffff";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-w*.25, top+6);
ctx.lineTo(-w*.18, bottom-7);
ctx.stroke();
ctx.beginPath();
ctx.ellipse(0, top, w*.46, 3, 0, 0, Math.PI*2);
ctx.stroke();
}
function drawBowl(o, w, h) {
const top = -h/2, bottom = h/2;
ctx.fillStyle = o.color;
ctx.strokeStyle = o.accent;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(-w/2, top);
ctx.quadraticCurveTo(-w*.42,bottom*.65,-w*.20,bottom);
ctx.lineTo(w*.20,bottom);
ctx.quadraticCurveTo(w*.42,bottom*.65,w/2,top);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#62453d33";
ctx.beginPath();
ctx.ellipse(0, top, w*.48, Math.max(3,h*.12), 0, 0, Math.PI*2);
ctx.fill();
ctx.strokeStyle = "#fff7";
ctx.beginPath();
ctx.moveTo(-w*.31, top+h*.28);
ctx.quadraticCurveTo(-w*.22,bottom*.45,0,bottom*.66);
ctx.stroke();
if (o.kind === "serving") {
const colors = ["#d7b34d","#73844b","#d48845"];
for (let i=0;i<3;i++) {
ctx.fillStyle = colors[i];
ctx.beginPath();
ctx.arc((i-1)*w*.18, top-2-(i%2)*3, w*.105, 0, Math.PI*2);
ctx.fill();
}
}
}
function drawCandle(o, w, h, time) {
const top = -h/2, bottom = h/2;
ctx.fillStyle = o.color;
ctx.strokeStyle = o.accent;
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.ellipse(0,bottom-4,w*.49,5,0,0,Math.PI*2);
ctx.fill();ctx.stroke();
roundedRect(-w*.065, top+h*.46, w*.13, bottom-top-h*.46-7, 2);
ctx.fill();ctx.stroke();
ctx.beginPath();
ctx.ellipse(0,top+h*.46,w*.25,4,0,0,Math.PI*2);
ctx.fill();ctx.stroke();
ctx.fillStyle = "#f2dfae";
ctx.strokeStyle = "#a88b5c";
roundedRect(-w*.105, top+15, w*.21, h*.30, 2);
ctx.fill();ctx.stroke();
ctx.fillStyle = "#fff7d8";
ctx.fillRect(-w*.065,top+17,w*.055,h*.25);
if (o.lit && Math.abs(normAngle(o.angle)) < .45) {
const flicker = Math.sin(time*13 + o.id)*1.7;
const glow = ctx.createRadialGradient(0,top+5,1,0,top+5,21);
glow.addColorStop(0,"#ffd87566");
glow.addColorStop(1,"#ffd87500");
ctx.fillStyle = glow;
ctx.beginPath();ctx.arc(0,top+5,21,0,Math.PI*2);ctx.fill();
ctx.fillStyle = "#d95d32";
ctx.beginPath();
ctx.moveTo(0,top+13);
ctx.bezierCurveTo(-7,top+5,-2+flicker,top-5,1+flicker,top-9);
ctx.bezierCurveTo(8,top+2,6,top+9,0,top+13);
ctx.fill();
ctx.fillStyle = "#ffe58c";
ctx.beginPath();
ctx.ellipse(1,top+5,2.5,6,0,0,Math.PI*2);
ctx.fill();
}
}
function drawObject(o, time) {
const w = o.w * S, h = o.h * S;
ctx.save();
ctx.translate(X(o.x), TABLE_Y - o.z * S);
ctx.rotate(o.angle);
if (o.kind === "plate") drawPlate(o,w,h);
else if (o.kind === "glass") drawGlass(o,w,h);
else if (o.kind === "candle") drawCandle(o,w,h,time);
else drawBowl(o,w,h);
ctx.restore();
}
function drawScale() {
ctx.strokeStyle = "#fff8";
ctx.fillStyle = "#fff9";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(26,H-25);ctx.lineTo(26+S,H-25);
ctx.moveTo(26,H-30);ctx.lineTo(26,H-20);
ctx.moveTo(26+S,H-30);ctx.lineTo(26+S,H-20);
ctx.stroke();
ctx.font = "11px system-ui";
ctx.textAlign = "left";
ctx.fillText("1 ft",26,H-9);
}
function draw(time) {
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.clearRect(0,0,W,H);
drawBackground();
drawTable();
drawCloth();
drawShadows();
for (const o of objects) drawObject(o,time);
drawScale();
}
function setupCanvas() {
DPR = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
}
// ---------- Interface ----------
function buildSpecs() {
const body = document.getElementById("specs");
for (const d of DEFS) {
const row = document.createElement("tr");
const values = [
d.name,
d.m.toFixed(2) + " kg",
(d.w*12).toFixed(1) + " in wide · " + d.area.toFixed(2) + " ft²",
d.muC.toFixed(2)
];
for (const value of values) {
const cell = document.createElement("td");
cell.textContent = value;
row.appendChild(cell);
}
body.appendChild(row);
}
}
function speedChanged(v) {
if (finished) resetTable();
setSpeed(v);
}
speedRange.addEventListener("input", () => speedChanged(Number(speedRange.value)));
speedNumber.addEventListener("change", () => {
if (Number.isFinite(Number(speedNumber.value))) speedChanged(Number(speedNumber.value));
});
yankButton.addEventListener("click", yank);
resetButton.addEventListener("click", resetTable);
document.addEventListener("keydown", e => {
if (e.target.closest("input,button,summary")) return;
if (e.code === "Space") {
e.preventDefault();
yank();
} else if (e.key.toLowerCase() === "r") {
resetTable();
}
});
let previous = performance.now();
function frame(now) {
const realDt = Math.min(.05, Math.max(0,(now-previous)/1000));
previous = now;
if (running) {
accumulator += realDt;
// Limit cloth travel per step as well as time, so fast yanks remain resolved.
const h = Math.min(1/120, .018 / Math.max(1,speed));
let steps = 0;
while (running && accumulator >= h && steps < 4000) {
physicsStep(h);
accumulator -= h;
steps++;
}
if (steps === 4000) accumulator = 0;
if (running) {
statusEl.textContent = clothActive
? "Pulling at " + formatSpeed(speed) + " ft/s · " + pulled.toFixed(1) + " ft of hand travel."
: "The cloth is clear · waiting for sliding, rocking and bouncing to stop…";
}
}
draw(now/1000);
requestAnimationFrame(frame);
}
buildSpecs();
setupCanvas();
resetTable();
setSpeed(12);
let autoPull = false;
try {
const params = new URLSearchParams(location.hash.slice(1));
if (params.has("speed")) {
const requested = Number(params.get("speed"));
if (Number.isFinite(requested)) {
setSpeed(requested);
autoPull = true;
}
}
} catch (_) {
// A malformed hash simply leaves the default setting.
}
window.addEventListener("resize", setupCanvas);
requestAnimationFrame(frame);
if (autoPull) {
setTimeout(() => {
if (!running && !hasPulled) yank();
}, 1000);
}
})();
</script>
</body>
</html>
The slow-pull ride-along and fast-pull short impulse emerge from Coulomb friction, not speed-keyed outcomes: speed only sets the cloth’s velocity. The simulation includes dry sliding friction, static riding once an object matches the cloth, momentum-conserving horizontal collisions, gravity, floor bounces and a corner-pivot rocking equation. A brief acceleration can make the candlestick rock and recover; a longer one can carry its centre of mass past the pivot and tip it.
It is deliberately a 2-D hybrid, not a general rigid-body engine. The artwork is wrapped around rectangular collision bounds. Contact area does not increase friction force, objects cannot break, spill, stack or roll, and collisions have only horizontal impulses plus a small rocking kick. The cloth neither bunches nor stretches; its ripple, final floor pile and the candle flame are decorative. A body falls when its centre crosses the table edge, receiving a small edge-tip spin. After one floor bounce it is snapped to a nearby stable face so the simulation can reliably sleep. The upright count means upright among the objects still on the table.
Expect failure around the middle settings: at roughly 10–12 ft/s the candlestick can rock into its neighbours while the right-hand bowl makes a long slide, and a collision chain can produce a worse result than a slightly different speed. Consequently, outcomes are not guaranteed to improve monotonically with speed. Near 23 ft/s the serving bowl stops with only a few hundredths of a foot to spare, so that boundary is delicate. Very slow nonzero pulls can take a while because they are simulated rather than skipped. Finally, 0 ft/s is the trivial exception to the low-speed prediction: nothing moves and all seven remain upright.