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.
In this simulation, $g = 32.174\text{ ft/s}^2$, the oak table spans $x \in [0, 4.4]\text{ ft}$, and the cloth ends on the left at $x_{\text{tail}} = 0.08\text{ ft}$ and is yanked right at constant speed $V$ after ramping up across $t_r = 0.015\text{ s}$. Because normal force and inertia are both proportional to mass $m$, pure sliding acceleration $a = \mu g$ cancels mass ($0.35\text{ kg}$ wine glass, $0.95\text{ kg}$ full carafe, $0.68\text{ kg}$ brass candlestick, $0.85\text{ kg}$ ceramic soup bowl, $0.60\text{ kg}$ dinner plate, $0.22\text{ kg}$ salt cellar), though contact width $2r_b$ and center-of-mass height $h_{\text{cm}}$ govern tipping via torque $\tau = F_f h_{\text{cm}} \mp N r_b$. For a fast pull ($V > \mu_s g t_r \approx 1.7\text{ ft/s}$), an object slips immediately at kinetic cloth friction $\mu_{k,c}$ while the cloth travels $L_i = x_i - x_{\text{tail}}$, accelerating for $t_c \approx L_i / V$ to velocity $v_1 = \mu_{k,c} g L_i / V$ and then skidding on bare wood ($\mu_{k,t}$) to a total displacement $\Delta x_i = \frac{\mu_{k,c} g L_i^2}{2 V^2}\left(1 + \frac{\mu_{k,c}}{\mu_{k,t}}\right)$. The rightmost object—the salt cellar at $x_6 = 3.85\text{ ft}$ ($L_6 = 3.77\text{ ft}$, $\mu_{k,c} = 0.25$, $\mu_{k,t} = 0.30$, allowable travel before its center passes the $4.40\text{ ft}$ edge $= 0.55\text{ ft}$)—sets the survival ceiling, requiring $V \ge \sqrt{\frac{0.25 \times 32.174 \times 3.77^2 (1 + 0.25/0.30)}{2 \times 0.55}} = \mathbf{13.8\text{ ft/s}}$; above $13.8\text{ ft/s}$ every object stays on the table (and because the narrowest tall item, the candlestick with $r_b/h_{\text{cm}} = 0.145/0.46 = 0.315 > \mu_{k,c} = 0.22$, only tips when striking the cloth seam or bare wood at high $v_1$, all six remain upright too). Conversely, the leftmost object—the wine glass at $x_1 = 0.68\text{ ft}$ ($L_1 = 0.60\text{ ft}$, $\mu_{s,c} = 0.34$, $\mu_{k,c} = 0.26$, $\mu_{k,t} = 0.32$, distance to the $4.40\text{ ft}$ drop-off $= 3.72\text{ ft}$)—stays locked to the cloth whenever $V$ cannot break static friction during the ramp ($V \le \mu_{s,c} g t_r = 0.16\text{ ft/s}$), and even if static friction were broken, once it rides the cloth to the edge at speed $V$ and hits the bare wood it falls off unless $L_1$ is shorter than the table, wait: riding locked on the cloth moves it $3.72\text{ ft}$ right onto the floor for every pull that never slips, and with the seam tug plus ramp lockup, below $\mathbf{1.8\text{ ft/s}}$ no object stays on the table.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Tablecloth Trick</title>
<style>
:root {
--bg-top: #1c1613;
--bg-bot: #2a201b;
--panel: #251c17;
--panel-border: #4a382d;
--gold: #e2b354;
--gold-hi: #f5cd79;
--cream: #f4eadc;
--muted: #b39e88;
--crimson: #9e2a2b;
--crimson-hi: #be3738;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: radial-gradient(circle at 50% 28%, #362820 0%, var(--bg-top) 75%);
color: var(--cream);
font-family: Georgia, 'Times New Roman', serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 14px;
overflow-x: hidden;
user-select: none;
}
.frame {
width: 100%;
max-width: 1060px;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 10px;
box-shadow: 0 24px 60px rgba(0,0,0,0.65);
overflow: hidden;
}
header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: baseline;
padding: 14px 22px 10px;
border-bottom: 1px solid var(--panel-border);
background: linear-gradient(180deg, rgba(255,255,255,0.03), transparent);
}
h1 {
font-size: 1.35rem;
font-weight: normal;
letter-spacing: 0.04em;
color: var(--gold-hi);
}
.subtitle {
font-size: 0.86rem;
color: var(--muted);
font-style: italic;
}
.stage-wrap {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #18120f;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
.banner {
position: absolute;
top: 18px;
left: 50%;
transform: translateX(-50%) translateY(-8px);
background: rgba(24, 17, 14, 0.92);
border: 1px solid var(--gold);
border-radius: 8px;
padding: 12px 24px;
text-align: center;
box-shadow: 0 12px 32px rgba(0,0,0,0.65);
opacity: 0;
pointer-events: none;
transition: opacity 0.28s ease, transform 0.28s ease;
}
.banner.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.banner .big-count {
font-size: 1.25rem;
color: var(--gold-hi);
margin-bottom: 4px;
}
.banner .detail {
font-size: 0.88rem;
color: var(--cream);
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 22px;
background: #1f1713;
border-top: 1px solid var(--panel-border);
}
.slider-group {
display: flex;
align-items: center;
gap: 14px;
flex: 1 1 360px;
}
.slider-label {
font-size: 0.95rem;
color: var(--muted);
white-space: nowrap;
}
.speed-readout {
font-family: 'Courier New', Courier, monospace;
font-weight: bold;
font-size: 1.2rem;
color: var(--gold-hi);
min-width: 96px;
text-align: right;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 7px;
background: #3b2d24;
border-radius: 4px;
outline: none;
border: 1px solid #534034;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--gold);
border: 2px solid #fff3d6;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
}
input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--gold);
border: 2px solid #fff3d6;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
}
.btn-group {
display: flex;
gap: 10px;
align-items: center;
}
button {
font-family: Georgia, serif;
font-size: 0.98rem;
padding: 9px 20px;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
transition: transform 0.08s ease, background 0.15s ease, opacity 0.15s ease;
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-yank {
background: linear-gradient(180deg, var(--crimson-hi), var(--crimson));
color: #fff8f0;
border-color: #d45b5c;
font-weight: bold;
letter-spacing: 0.04em;
box-shadow: 0 4px 14px rgba(158, 42, 43, 0.45);
}
.btn-yank:hover:not(:disabled) {
background: linear-gradient(180deg, #cf4243, #ad2e2f);
}
.btn-reset {
background: #332720;
color: var(--cream);
border-color: #594438;
}
.btn-reset:hover:not(:disabled) {
background: #42332a;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 14px 22px;
padding: 9px 22px 12px;
font-size: 0.78rem;
color: var(--muted);
background: #1a1310;
border-top: 1px solid #33261e;
}
.legend span strong {
color: var(--cream);
font-weight: normal;
}
</style>
</head>
<body>
<div class="frame">
<header>
<h1>Uncle Arthur’s Holiday Tablecloth Trick</h1>
<div class="subtitle">Six heirlooms, one damask cloth, and Coulomb friction</div>
</header>
<div class="stage-wrap">
<canvas id="sim" width="1120" height="630"></canvas>
<div id="banner" class="banner" aria-live="polite">
<div id="bannerMain" class="big-count"></div>
<div id="bannerSub" class="detail"></div>
</div>
</div>
<div class="controls">
<div class="slider-group">
<label class="slider-label" for="speedRange">Pull Speed</label>
<input id="speedRange" type="range" min="0.5" max="32" step="0.1" value="9.5">
<div id="speedVal" class="speed-readout">9.5 ft/s</div>
</div>
<div class="btn-group">
<button id="yankBtn" class="btn-yank">YANK CLOTH!</button>
<button id="resetBtn" class="btn-reset">Reset Table</button>
</div>
</div>
<div class="legend" id="legendBar"></div>
</div>
<script>
(() => {
'use strict';
const canvas = document.getElementById('sim');
const ctx = canvas.getContext('2d');
const speedRange = document.getElementById('speedRange');
const speedVal = document.getElementById('speedVal');
const yankBtn = document.getElementById('yankBtn');
const resetBtn = document.getElementById('resetBtn');
const banner = document.getElementById('banner');
const bannerMain = document.getElementById('bannerMain');
const bannerSub = document.getElementById('bannerSub');
const legendBar = document.getElementById('legendBar');
// Physical constants (feet, seconds, kilograms — forces in kg*ft/s^2)
const G = 32.174;
const PIXELS_PER_FOOT = 148;
const ORIGIN_X = 95; // x = 0 ft (left edge of table) in canvas pixels
const TABLE_TOP_PX = 365; // y = 0 ft (tabletop surface) in canvas pixels
// World coordinates: x rightwards in ft, y UPWARDS in ft (tabletop = 0, floor = -2.35 ft)
const TABLE_LEN = 4.40;
const TABLE_THICK = 0.18;
const FLOOR_Y = -1.62; // Floor surface in ft below tabletop (240 px below table)
const CLOTH_THICK = 0.016;
const CLOTH_HEM_BUMP = 0.014; // Stitched left hem gives a slight impulse as it passes
function wx(xFt) { return ORIGIN_X + xFt * PIXELS_PER_FOOT; }
function wy(yFt) { return TABLE_TOP_PX - yFt * PIXELS_PER_FOOT; }
// Six distinct dinner items differing in mass, base contact width, height, and friction
const CATALOG = [
{
id: 'glass',
name: 'Wine Glass',
mass: 0.35,
x0: 0.68,
baseHalf: 0.115,
halfW: 0.13,
height: 0.66,
cmY: 0.29,
muSC: 0.34, muKC: 0.26, muST: 0.40, muKT: 0.32,
restitution: 0.16,
fragile: true,
hull: [
[-0.115, 0.00], [0.115, 0.00],
[0.13, 0.42], [0.11, 0.66],
[-0.11, 0.66], [-0.13, 0.42]
]
},
{
id: 'plate',
name: 'Dinner Plate',
mass: 0.60,
x0: 1.38,
baseHalf: 0.28,
halfW: 0.40,
height: 0.085,
cmY: 0.036,
muSC: 0.31, muKC: 0.23, muST: 0.36, muKT: 0.29,
restitution: 0.12,
fragile: true,
hull: [
[-0.28, 0.00], [0.28, 0.00],
[0.40, 0.065], [0.36, 0.085],
[-0.36, 0.085], [-0.40, 0.065]
]
},
{
id: 'candle',
name: 'Brass Candlestick',
mass: 0.68,
x0: 2.12,
baseHalf: 0.145,
halfW: 0.145,
height: 0.98,
cmY: 0.46,
muSC: 0.30, muKC: 0.22, muST: 0.35, muKT: 0.28,
restitution: 0.18,
fragile: false,
hull: [
[-0.145, 0.00], [0.145, 0.00],
[0.09, 0.62], [0.045, 0.98],
[-0.045, 0.98], [-0.09, 0.62]
]
},
{
id: 'bowl',
name: 'Soup Bowl',
mass: 0.85,
x0: 2.75,
baseHalf: 0.17,
halfW: 0.27,
height: 0.27,
cmY: 0.115,
muSC: 0.33, muKC: 0.24, muST: 0.38, muKT: 0.30,
restitution: 0.14,
fragile: true,
hull: [
[-0.17, 0.00], [0.17, 0.00],
[0.27, 0.22], [0.25, 0.27],
[-0.25, 0.27], [-0.27, 0.22]
]
},
{
id: 'carafe',
name: 'Water Carafe',
mass: 0.95,
x0: 3.38,
baseHalf: 0.155,
halfW: 0.175,
height: 0.74,
cmY: 0.28,
muSC: 0.32, muKC: 0.24, muST: 0.38, muKT: 0.30,
restitution: 0.14,
fragile: true,
hull: [
[-0.155, 0.00], [0.155, 0.00],
[0.18, 0.28], [0.10, 0.74],
[-0.10, 0.74], [-0.18, 0.28]
]
},
{
id: 'salt',
name: 'Salt Cellar',
mass: 0.22,
x0: 3.85,
baseHalf: 0.075,
halfW: 0.08,
height: 0.24,
cmY: 0.105,
muSC: 0.33, muKC: 0.25, muST: 0.38, muKT: 0.30,
restitution: 0.20,
fragile: false,
hull: [
[-0.075, 0.00], [0.075, 0.00],
[0.08, 0.18], [0.06, 0.24],
[-0.06, 0.24], [-0.08, 0.18]
]
}
];
legendBar.innerHTML = CATALOG.map(c =>
`<span><strong>${c.name}:</strong> ${(c.mass * 1000).toFixed(0)}g, base ${(c.baseHalf * 24).toFixed(1)}"</span>`
).join('');
let pullTargetSpeed = 9.5; // ft/s
let state = 'ready'; // 'ready' | 'pulling' | 'settling' | 'done'
let simTime = 0;
let settleTimer = 0;
let autoPullTimeout = null;
let cloth = null;
let bodies = [];
let droplets = [];
let sparks = [];
function resetScene() {
if (autoPullTimeout) {
clearTimeout(autoPullTimeout);
autoPullTimeout = null;
}
state = 'ready';
simTime = 0;
settleTimer = 0;
banner.classList.remove('visible');
yankBtn.disabled = false;
cloth = {
tailX: 0.08, // Left stitched hem on tabletop
frontX: TABLE_LEN, // Right edge of tabletop where overhang begins
drapeLen: 0.78, // Hanging overhang on the right
vx: 0,
pullTime: 0,
gone: false,
handX: TABLE_LEN + 0.34,
handY: -0.34,
wavePhase: 0
};
droplets = [];
sparks = [];
bodies = CATALOG.map(spec => {
// Moment of inertia of solid cylinder/prism approximation around CM
const w = spec.baseHalf * 2;
const h = spec.height;
const inertia = spec.mass * ((w * w + h * h) / 12) * 1.15;
const localVerts = spec.hull.map(([lx, ly]) => ({ x: lx, y: ly - spec.cmY }));
let boundR = 0;
for (const v of localVerts) {
boundR = Math.max(boundR, Math.hypot(v.x, v.y));
}
return {
spec,
mass: spec.mass,
invMass: 1 / spec.mass,
inertia,
invInertia: 1 / inertia,
x: spec.x0,
y: CLOTH_THICK + spec.cmY,
vx: 0,
vy: 0,
angle: 0, // Positive = CCW in world (x right, y up)
omega: 0,
localVerts,
boundR,
hemHit: false,
liquidAngle: 0,
liquidOmega: 0,
spilled: 0,
flameLean: 0,
flameOut: false,
chipped: false
};
});
}
function worldVerts(b) {
const c = Math.cos(b.angle);
const s = Math.sin(b.angle);
const out = new Array(b.localVerts.length);
for (let i = 0; i < b.localVerts.length; i++) {
const lv = b.localVerts[i];
out[i] = {
x: b.x + lv.x * c - lv.y * s,
y: b.y + lv.x * s + lv.y * c
};
}
return out;
}
function startYank() {
if (state !== 'ready') return;
state = 'pulling';
yankBtn.disabled = true;
banner.classList.remove('visible');
}
// Impulse resolution at contact point (px, py) with unit normal (nx, ny) pointing onto A (or body)
function applySurfaceContact(b, px, py, nx, ny, penetration, surfVx, muS, muK, restitution, dt) {
const rx = px - b.x;
const ry = py - b.y;
// Velocity of body point minus surface velocity
const vpx = (b.vx - b.omega * ry) - surfVx;
const vpy = (b.vy + b.omega * rx);
const vn = vpx * nx + vpy * ny;
// Positional Baumgarte / projection correction to stop sinking
const slop = 0.0012;
if (penetration > slop) {
const corr = (penetration - slop) * 0.45;
b.x += nx * corr;
b.y += ny * corr;
}
if (vn >= 0) return 0;
const rnCross = rx * ny - ry * nx;
const kNormal = b.invMass + rnCross * rnCross * b.invInertia;
// Suppress bounce on tiny resting velocities
const e = (-vn > 1.35) ? restitution : 0;
const jn = -(1 + e) * vn / kNormal;
if (jn <= 0) return 0;
b.vx += (jn * nx) * b.invMass;
b.vy += (jn * ny) * b.invMass;
b.omega += (rx * (jn * ny) - ry * (jn * nx)) * b.invInertia;
// Recompute relative velocity for Coulomb friction tangent
const tx = -ny;
const ty = nx;
const vpx2 = (b.vx - b.omega * ry) - surfVx;
const vpy2 = (b.vy + b.omega * rx);
const vt = vpx2 * tx + vpy2 * ty;
const rtCross = rx * ty - ry * tx;
const kTangent = b.invMass + rtCross * rtCross * b.invInertia;
const jtDesired = -vt / kTangent;
let jt;
if (Math.abs(jtDesired) <= muS * jn) {
jt = jtDesired; // Static lock
} else {
jt = -Math.sign(vt) * muK * jn; // Kinetic slide
}
b.vx += (jt * tx) * b.invMass;
b.vy += (jt * ty) * b.invMass;
b.omega += (rx * (jt * ty) - ry * (jt * tx)) * b.invInertia;
// Visual impact effects on hard hits
if (-vn > 3.8 && py <= FLOOR_Y + 0.08 && b.spec.fragile && !b.chipped) {
b.chipped = true;
spawnShards(px, py, b.spec.id);
}
return jn;
}
function spawnShards(x, y, id) {
const color = id === 'glass' || id === 'carafe' ? 'rgba(210,238,255,0.85)' : '#f2e8dc';
for (let i = 0; i < 9; i++) {
const ang = Math.random() * Math.PI;
const spd = 1.5 + Math.random() * 5.0;
sparks.push({
x, y: y + 0.02,
vx: Math.cos(ang) * spd * (Math.random() < 0.5 ? -1 : 1),
vy: Math.sin(ang) * spd + 1.2,
size: 2 + Math.random() * 3.5,
color,
life: 0.7 + Math.random() * 0.5
});
}
}
function spawnDroplet(x, y, vx, vy, color) {
if (droplets.length > 90) return;
droplets.push({ x, y, vx, vy, color, life: 1.4 });
}
// Separating Axis Theorem (SAT) collision between two convex hulls
function collideBodies(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const maxR = a.boundR + b.boundR;
if (dx * dx + dy * dy > maxR * maxR) return;
const va = worldVerts(a);
const vb = worldVerts(b);
let minOverlap = Infinity;
let bestNx = 0, bestNy = 0;
const checkAxes = (verts) => {
for (let i = 0; i < verts.length; i++) {
const p1 = verts[i];
const p2 = verts[(i + 1) % verts.length];
const ex = p2.x - p1.x;
const ey = p2.y - p1.y;
const len = Math.hypot(ex, ey);
if (len < 1e-7) continue;
// Outward normal for CCW polygon is (ey, -ex) / len
const nx = ey / len;
const ny = -ex / len;
let minA = Infinity, maxA = -Infinity;
for (let k = 0; k < va.length; k++) {
const proj = va[k].x * nx + va[k].y * ny;
if (proj < minA) minA = proj;
if (proj > maxA) maxA = proj;
}
let minB = Infinity, maxB = -Infinity;
for (let k = 0; k < vb.length; k++) {
const proj = vb[k].x * nx + vb[k].y * ny;
if (proj < minB) minB = proj;
if (proj > maxB) maxB = proj;
}
const overlap = Math.min(maxA, maxB) - Math.max(minA, minB);
if (overlap <= 0) return false;
if (overlap < minOverlap) {
minOverlap = overlap;
bestNx = nx;
bestNy = ny;
}
}
return true;
};
if (!checkAxes(va)) return;
if (!checkAxes(vb)) return;
// Orient normal from A toward B
if (dx * bestNx + dy * bestNy < 0) {
bestNx = -bestNx;
bestNy = -bestNy;
}
// Separate positions proportionally to inverse mass
const totalInvMass = a.invMass + b.invMass;
const sep = Math.max(0, minOverlap - 0.0008) * 0.65 / totalInvMass;
a.x -= bestNx * sep * a.invMass;
a.y -= bestNy * sep * a.invMass;
b.x += bestNx * sep * b.invMass;
b.y += bestNy * sep * b.invMass;
// Contact point: deepest vertex of B along -N or deepest of A along +N
let bestProjA = -Infinity, cpAx = a.x, cpAy = a.y;
for (let i = 0; i < va.length; i++) {
const p = va[i].x * bestNx + va[i].y * bestNy;
if (p > bestProjA) { bestProjA = p; cpAx = va[i].x; cpAy = va[i].y; }
}
let bestProjB = Infinity, cpBx = b.x, cpBy = b.y;
for (let i = 0; i < vb.length; i++) {
const p = vb[i].x * bestNx + vb[i].y * bestNy;
if (p < bestProjB) { bestProjB = p; cpBx = vb[i].x; cpBy = vb[i].y; }
}
const px = 0.5 * (cpAx + cpBx);
const py = 0.5 * (cpAy + cpBy);
const rAx = px - a.x, rAy = py - a.y;
const rBx = px - b.x, rBy = py - b.y;
const vAx = a.vx - a.omega * rAy;
const vAy = a.vy + a.omega * rAx;
const vBx = b.vx - b.omega * rBy;
const vBy = b.vy + b.omega * rBx;
const rvx = vBx - vAx;
const rvy = vBy - vAy;
const vn = rvx * bestNx + rvy * bestNy;
if (vn > 0) return;
const raCrossN = rAx * bestNy - rAy * bestNx;
const rbCrossN = rBx * bestNy - rBy * bestNx;
const kN = totalInvMass + raCrossN * raCrossN * a.invInertia + rbCrossN * rbCrossN * b.invInertia;
const e = (-vn > 1.0) ? 0.22 : 0.0;
const jn = -(1 + e) * vn / kN;
const impX = jn * bestNx;
const impY = jn * bestNy;
a.vx -= impX * a.invMass;
a.vy -= impY * a.invMass;
a.omega -= (rAx * impY - rAy * impX) * a.invInertia;
b.vx += impX * b.invMass;
b.vy += impY * b.invMass;
b.omega += (rBx * impY - rBy * impX) * b.invInertia;
// Tangential friction between colliding tableware
const tx = -bestNy, ty = bestNx;
const vAx2 = a.vx - a.omega * rAy;
const vAy2 = a.vy + a.omega * rAx;
const vBx2 = b.vx - b.omega * rBy;
const vBy2 = b.vy + b.omega * rBx;
const vt = (vBx2 - vAx2) * tx + (vBy2 - vAy2) * ty;
const raCrossT = rAx * ty - rAy * tx;
const rbCrossT = rBx * ty - rBy * tx;
const kT = totalInvMass + raCrossT * raCrossT * a.invInertia + rbCrossT * rbCrossT * b.invInertia;
const mu = 0.26;
const jt = Math.max(-mu * jn, Math.min(mu * jn, -vt / kT));
a.vx -= (jt * tx) * a.invMass;
a.vy -= (jt * ty) * a.invMass;
a.omega -= (rAx * jt * ty - rAy * jt * tx) * a.invInertia;
b.vx += (jt * tx) * b.invMass;
b.vy += (jt * ty) * b.invMass;
b.omega += (rBx * jt * ty - rBy * jt * tx) * b.invInertia;
}
function stepPhysics(dt) {
const substeps = 14;
const h = dt / substeps;
for (let s = 0; s < substeps; s++) {
simTime += h;
// 1. Advance tablecloth motion
if (state === 'pulling' && !cloth.gone) {
cloth.pullTime += h;
const ramp = 0.015; // 15 ms human wrist snap ramp-up
const frac = Math.min(1, cloth.pullTime / ramp);
cloth.vx = pullTargetSpeed * frac;
cloth.tailX += cloth.vx * h;
cloth.handX += cloth.vx * h;
// Slight downward yank angle as cloth clears the right edge
cloth.handY = -0.34 - Math.min(0.28, cloth.pullTime * 0.55);
cloth.wavePhase += cloth.vx * h * 8.5;
if (cloth.tailX > TABLE_LEN + 1.6) {
cloth.gone = true;
cloth.vx = 0;
state = 'settling';
}
}
// 2. Integrate external forces (gravity + subtle air/rolling damping)
for (let i = 0; i < bodies.length; i++) {
const b = bodies[i];
const prevVx = b.vx;
b.vy -= G * h;
b.vx *= (1 - 0.04 * h);
b.vy *= (1 - 0.02 * h);
b.omega *= (1 - 0.25 * h);
b.x += b.vx * h;
b.y += b.vy * h;
b.angle += b.omega * h;
// Sloshing internal liquid oscillator for glass, bowl, carafe
const ax = (b.vx - prevVx) / h;
const targetSlosh = Math.max(-0.7, Math.min(0.7, ax * 0.012)) - b.angle;
b.liquidOmega += ((targetSlosh - b.liquidAngle) * 65 - b.liquidOmega * 4.8) * h;
b.liquidAngle += b.liquidOmega * h;
// Spill droplets if tilted steeply or sloshing violently
const tiltTotal = Math.abs(b.angle + b.liquidAngle * 0.65);
if ((b.spec.id === 'glass' || b.spec.id === 'bowl' || b.spec.id === 'carafe') &&
tiltTotal > 0.58 && b.spilled < 1 && Math.random() < 0.35) {
b.spilled = Math.min(1, b.spilled + 0.022);
const c = Math.cos(b.angle), sn = Math.sin(b.angle);
const topY = b.spec.height - b.spec.cmY;
const rimX = b.x - sn * topY;
const rimY = b.y + c * topY;
const col = b.spec.id === 'glass' ? '#8c1d2c' : (b.spec.id === 'bowl' ? '#d98a3c' : 'rgba(175,220,245,0.85)');
spawnDroplet(rimX, rimY, b.vx + (Math.random() - 0.5) * 1.6, b.vy + 0.4, col);
}
// Candle flame physics
if (b.spec.id === 'candle') {
const flameTarget = Math.max(-1.1, Math.min(1.1, -b.vx * 0.14 - b.omega * 0.12));
b.flameLean += (flameTarget - b.flameLean) * Math.min(1, 14 * h);
if (Math.abs(b.angle) > 1.15 || b.y < -0.5) {
b.flameOut = true;
}
}
}
// 3. Cloth stitched hem micro-impulse when passing directly under an object's base
if (!cloth.gone && cloth.vx > 0.05) {
for (let i = 0; i < bodies.length; i++) {
const b = bodies[i];
if (!b.hemHit && cloth.tailX >= b.x - b.spec.baseHalf && cloth.tailX <= b.x + b.spec.baseHalf) {
const bottomY = b.y - b.spec.cmY * Math.cos(b.angle);
if (bottomY < CLOTH_THICK + 0.05 && b.x >= 0 && b.x <= TABLE_LEN) {
b.hemHit = true;
// Slower pulls allow the hem longer dwell time to kick the trailing edge upward/forward
const dwell = Math.min(1.0, 4.2 / Math.max(1.2, cloth.vx));
b.vy += 0.26 * dwell;
b.vx += 0.22 * dwell;
b.omega -= (0.34 * dwell) / Math.max(0.14, b.spec.baseHalf * 2.2);
}
}
}
}
// 4. Solve collisions against table, cloth, table corners, and floor
for (let iter = 0; iter < 2; iter++) {
for (let i = 0; i < bodies.length; i++) {
const b = bodies[i];
const verts = worldVerts(b);
for (let v = 0; v < verts.length; v++) {
const vx = verts[v].x;
const vy = verts[v].y;
// A. Tabletop / Cloth top contact
if (vx >= 0 && vx <= TABLE_LEN) {
const onCloth = !cloth.gone && vx >= cloth.tailX && vx <= TABLE_LEN;
const surfY = onCloth ? CLOTH_THICK : 0;
if (vy <= surfY && vy >= -TABLE_THICK * 0.75 && b.y > -TABLE_THICK * 0.5) {
const pen = surfY - vy;
const svx = onCloth ? cloth.vx : 0;
const muS = onCloth ? b.spec.muSC : b.spec.muST;
const muK = onCloth ? b.spec.muKC : b.spec.muKT;
applySurfaceContact(b, vx, vy, 0, 1, pen, svx, muS, muK, b.spec.restitution * 0.5, h);
}
}
// B. Right table corner (rounded lip at x = TABLE_LEN, y = 0)
const cornerR = 0.025;
if (vx > TABLE_LEN - 0.01 && vx < TABLE_LEN + cornerR * 2.2 && vy < cornerR && vy > -TABLE_THICK) {
const cx = TABLE_LEN;
const cy = -cornerR;
const dx = vx - cx;
const dy = vy - cy;
const dist = Math.hypot(dx, dy);
if (dist < cornerR && dist > 1e-5 && dx >= 0) {
const nx = dx / dist;
const ny = dy / dist;
const onClothCorner = !cloth.gone && cloth.tailX < TABLE_LEN;
const svx = onClothCorner ? cloth.vx * 0.7 : 0;
applySurfaceContact(b, vx, vy, nx, ny, cornerR - dist, svx, b.spec.muST, b.spec.muKT, 0.1, h);
}
}
// C. Left table corner (x = 0, y = 0)
if (vx < 0.01 && vx > -cornerR * 2.2 && vy < cornerR && vy > -TABLE_THICK) {
const cx = 0;
const cy = -cornerR;
const dx = vx - cx;
const dy = vy - cy;
const dist = Math.hypot(dx, dy);
if (dist < cornerR && dist > 1e-5 && dx <= 0) {
applySurfaceContact(b, vx, vy, dx / dist, dy / dist, cornerR - dist, 0, b.spec.muST, b.spec.muKT, 0.1, h);
}
}
// D. Floor contact
if (vy <= FLOOR_Y) {
const pen = FLOOR_Y - vy;
applySurfaceContact(b, vx, vy, 0, 1, pen, 0, 0.48, 0.36, b.spec.restitution, h);
}
// E. Stage side boundaries (keep fallen items visible inside room)
const leftWall = -0.58;
const rightWall = 6.55;
if (vx < leftWall) {
applySurfaceContact(b, vx, vy, 1, 0, leftWall - vx, 0, 0.3, 0.25, 0.2, h);
} else if (vx > rightWall) {
applySurfaceContact(b, vx, vy, -1, 0, vx - rightWall, 0, 0.3, 0.25, 0.2, h);
}
}
}
// 5. Object-to-object collisions
for (let i = 0; i < bodies.length; i++) {
for (let j = i + 1; j < bodies.length; j++) {
collideBodies(bodies[i], bodies[j]);
}
}
}
// 6. Sleep micro-jitter suppression for resting contacts
for (let i = 0; i < bodies.length; i++) {
const b = bodies[i];
const clothUnderBody = !cloth.gone && b.x >= cloth.tailX - 0.2 && b.x <= TABLE_LEN + 0.2;
if (!clothUnderBody) {
const speedSq = b.vx * b.vx + b.vy * b.vy;
if (speedSq < 0.0025 && Math.abs(b.omega) < 0.06) {
b.vx *= 0.85;
b.vy *= 0.85;
b.omega *= 0.80;
}
}
}
}
// Update particles once per frame
for (let i = droplets.length - 1; i >= 0; i--) {
const p = droplets[i];
p.vy -= G * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.y <= FLOOR_Y) {
p.y = FLOOR_Y;
p.vy = 0;
p.vx *= 0.6;
} else if (p.x >= 0 && p.x <= TABLE_LEN && p.y <= 0 && p.y > -0.08) {
p.y = 0;
p.vy = 0;
p.vx *= 0.6;
}
if (p.life <= 0) droplets.splice(i, 1);
}
for (let i = sparks.length - 1; i >= 0; i--) {
const p = sparks[i];
p.vy -= G * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.y <= FLOOR_Y) {
p.y = FLOOR_Y;
p.vy = Math.abs(p.vy) * 0.25;
p.vx *= 0.7;
}
if (p.life <= 0) sparks.splice(i, 1);
}
// Check stopping criteria once cloth has cleared the table
if (state === 'settling') {
let allQuiet = true;
for (let i = 0; i < bodies.length; i++) {
const b = bodies[i];
if (Math.hypot(b.vx, b.vy) > 0.045 || Math.abs(b.omega) > 0.075) {
allQuiet = false;
break;
}
// Still falling in midair?
if (b.y > FLOOR_Y + b.boundR + 0.08 && (b.x < -0.05 || b.x > TABLE_LEN + 0.05)) {
allQuiet = false;
break;
}
}
if (allQuiet) {
settleTimer += dt;
if (settleTimer >= 0.38) {
finishRun();
}
} else {
settleTimer = 0;
}
}
}
function finishRun() {
state = 'done';
let onTable = 0;
let uprightOnTable = 0;
for (const b of bodies) {
const isOnTable = b.x >= -0.05 && b.x <= TABLE_LEN + 0.08 && b.y > -0.12;
const normAngle = Math.atan2(Math.sin(b.angle), Math.cos(b.angle));
const isUpright = Math.abs(normAngle) < 0.32; // Within ~18 degrees of vertical
if (isOnTable) {
onTable++;
if (isUpright) uprightOnTable++;
}
}
const total = bodies.length;
bannerMain.textContent = `${onTable} of ${total} on the table · ${uprightOnTable} of ${total} upright`;
let verdict = '';
if (uprightOnTable === total) {
verdict = `Clean yank at ${pullTargetSpeed.toFixed(1)} ft/s! Grandma’s china lives to see dessert.`;
} else if (onTable === total) {
verdict = `Everything stayed aboard at ${pullTargetSpeed.toFixed(1)} ft/s, though ${total - uprightOnTable} tipped over.`;
} else if (onTable === 0) {
verdict = `Total wipeout at ${pullTargetSpeed.toFixed(1)} ft/s. Silence around the dining room.`;
} else {
verdict = `Partial carnage at ${pullTargetSpeed.toFixed(1)} ft/s: ${total - onTable} hit the floorboards.`;
}
bannerSub.textContent = verdict;
banner.classList.add('visible');
}
// ===================== RENDERING =====================
function drawBackground() {
const W = canvas.width;
const H = canvas.height;
const floorPx = wy(FLOOR_Y);
// Warm dining room wallpaper gradient
const wallGrad = ctx.createLinearGradient(0, 0, 0, floorPx);
wallGrad.addColorStop(0, '#231915');
wallGrad.addColorStop(0.65, '#2f211b');
wallGrad.addColorStop(1, '#1e1511');
ctx.fillStyle = wallGrad;
ctx.fillRect(0, 0, W, floorPx);
// Subtle wallpaper vertical stripes
ctx.fillStyle = 'rgba(255, 225, 185, 0.018)';
for (let x = 20; x < W; x += 48) {
ctx.fillRect(x, 0, 16, floorPx - 28);
}
// Framed picture on dining room wall
const picX = 270, picY = 48, picW = 150, picH = 104;
ctx.fillStyle = '#18110e';
ctx.fillRect(picX + 5, picY + 7, picW, picH);
ctx.fillStyle = '#5c4228';
ctx.fillRect(picX, picY, picW, picH);
ctx.strokeStyle = '#8b693e';
ctx.lineWidth = 2;
ctx.strokeRect(picX + 4, picY + 4, picW - 8, picH - 8);
ctx.fillStyle = '#2b302c';
ctx.fillRect(picX + 12, picY + 12, picW - 24, picH - 24);
// Tiny still-life hills inside frame
ctx.fillStyle = '#475243';
ctx.beginPath();
ctx.moveTo(picX + 12, picY + picH - 12);
ctx.quadraticCurveTo(picX + 55, picY + 48, picX + 98, picY + 74);
ctx.quadraticCurveTo(picX + 120, picY + 58, picX + picW - 12, picY + picH - 12);
ctx.fill();
// Wainscoting chair rail & baseboard
ctx.fillStyle = '#281c16';
ctx.fillRect(0, floorPx - 145, W, 145);
ctx.fillStyle = '#3d2c22';
ctx.fillRect(0, floorPx - 148, W, 6);
ctx.fillStyle = '#1a120e';
ctx.fillRect(0, floorPx - 22, W, 22);
// Hardwood floor
const floorGrad = ctx.createLinearGradient(0, floorPx, 0, H);
floorGrad.addColorStop(0, '#3b2618');
floorGrad.addColorStop(0.3, '#4a301f');
floorGrad.addColorStop(1, '#291910');
ctx.fillStyle = floorGrad;
ctx.fillRect(0, floorPx, W, H - floorPx);
// Plank lines
ctx.strokeStyle = 'rgba(18, 10, 5, 0.45)';
ctx.lineWidth = 1;
for (let y = floorPx + 14; y < H; y += 18) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(W, y);
ctx.stroke();
}
}
function drawTable() {
const x0 = wx(0);
const x1 = wx(TABLE_LEN);
const yTop = wy(0);
const thickPx = TABLE_THICK * PIXELS_PER_FOOT;
const floorPx = wy(FLOOR_Y);
// Floor shadow under table
const shGrad = ctx.createRadialGradient((x0 + x1) * 0.5, floorPx + 6, 40, (x0 + x1) * 0.5, floorPx + 6, (x1 - x0) * 0.62);
shGrad.addColorStop(0, 'rgba(0,0,0,0.55)');
shGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = shGrad;
ctx.fillRect(x0 - 60, floorPx - 6, (x1 - x0) + 160, 28);
// Turned wooden legs
const drawLeg = (lx) => {
const legGrad = ctx.createLinearGradient(lx - 14, 0, lx + 14, 0);
legGrad.addColorStop(0, '#3a2214');
legGrad.addColorStop(0.4, '#6e4429');
legGrad.addColorStop(1, '#2d190e');
ctx.fillStyle = legGrad;
ctx.beginPath();
ctx.moveTo(lx - 15, yTop + thickPx);
ctx.lineTo(lx + 15, yTop + thickPx);
ctx.lineTo(lx + 12, yTop + thickPx + 55);
ctx.quadraticCurveTo(lx + 17, yTop + thickPx + 75, lx + 11, yTop + thickPx + 95);
ctx.lineTo(lx + 8, floorPx);
ctx.lineTo(lx - 8, floorPx);
ctx.lineTo(lx - 11, yTop + thickPx + 95);
ctx.quadraticCurveTo(lx - 17, yTop + thickPx + 75, lx - 12, yTop + thickPx + 55);
ctx.closePath();
ctx.fill();
};
drawLeg(x0 + 44);
drawLeg(x1 - 44);
// Apron beam under tabletop
ctx.fillStyle = '#472b19';
ctx.fillRect(x0 + 34, yTop + thickPx - 2, (x1 - x0) - 68, 22);
ctx.fillStyle = 'rgba(0,0,0,0.32)';
ctx.fillRect(x0 + 34, yTop + thickPx - 2, (x1 - x0) - 68, 5);
// Solid oak tabletop slab with bullnose corners
const slabGrad = ctx.createLinearGradient(0, yTop, 0, yTop + thickPx);
slabGrad.addColorStop(0, '#8c5835');
slabGrad.addColorStop(0.22, '#734527');
slabGrad.addColorStop(1, '#4a2b17');
ctx.fillStyle = slabGrad;
ctx.beginPath();
ctx.roundRect(x0, yTop, x1 - x0, thickPx, [4, 4, 5, 5]);
ctx.fill();
// Specular polish highlight on bare oak surface
ctx.fillStyle = 'rgba(255, 232, 200, 0.16)';
ctx.fillRect(x0 + 3, yTop + 1, x1 - x0 - 6, 3);
}
function drawTablecloth() {
if (cloth.gone) return;
const totalClothLen = (TABLE_LEN - 0.08) + cloth.drapeLen;
const tailFt = cloth.tailX;
const tipFt = tailFt + totalClothLen;
const yClothTop = wy(CLOTH_THICK);
const yTable = wy(0);
const xEdge = wx(TABLE_LEN);
ctx.save();
// 1. Flat section still resting on the oak tabletop
if (tailFt < TABLE_LEN) {
const xStart = wx(Math.max(0, tailFt));
const xEnd = xEdge;
const clothGrad = ctx.createLinearGradient(0, yClothTop - 3, 0, yTable);
clothGrad.addColorStop(0, '#b52b31');
clothGrad.addColorStop(1, '#821b20');
ctx.fillStyle = clothGrad;
ctx.fillRect(xStart, yClothTop, xEnd - xStart, yTable - yClothTop + 2);
// Gold damask border weave along cloth
ctx.fillStyle = '#dfb15b';
ctx.fillRect(xStart, yClothTop + 1, xEnd - xStart, 1.5);
// Left stitched rolled hem
if (tailFt >= 0) {
ctx.fillStyle = '#cf3a40';
ctx.beginPath();
ctx.arc(xStart + 2, yClothTop + 2, 3.5, 0, Math.PI * 2);
ctx.fill();
}
}
// 2. Overhanging / yanked ribbon streaming off the right side toward the pulling hand
const startRibbonFt = Math.max(tailFt, TABLE_LEN);
const handPxX = wx(cloth.handX);
const handPxY = wy(cloth.handY);
if (state === 'ready') {
// Neat vertical dining room overhang drape with gold fringe
const drapeBottomY = wy(-cloth.drapeLen);
const drapeGrad = ctx.createLinearGradient(xEdge - 6, yClothTop, xEdge + 28, drapeBottomY);
drapeGrad.addColorStop(0, '#a62429');
drapeGrad.addColorStop(0.5, '#8c1d22');
drapeGrad.addColorStop(1, '#6e1418');
ctx.fillStyle = drapeGrad;
ctx.beginPath();
ctx.moveTo(xEdge - 3, yClothTop);
ctx.quadraticCurveTo(xEdge + 12, yClothTop + 4, xEdge + 16, yClothTop + 26);
ctx.lineTo(xEdge + 26, drapeBottomY);
ctx.lineTo(xEdge + 4, drapeBottomY - 4);
ctx.lineTo(xEdge - 3, yTable + 3);
ctx.closePath();
ctx.fill();
// Gold hem trim & tassels
ctx.strokeStyle = '#e2b354';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(xEdge + 4, drapeBottomY - 4);
ctx.lineTo(xEdge + 26, drapeBottomY);
ctx.stroke();
// Waiting hand gripping the hem
drawHand(xEdge + 24, drapeBottomY - 12, false);
} else {
// Dynamic billowing cloth pulled to the right
const anchorX = tailFt < TABLE_LEN ? xEdge : wx(tailFt);
const anchorY = tailFt < TABLE_LEN ? yClothTop : yClothTop + Math.min(85, (tailFt - TABLE_LEN) * 55);
const spanX = Math.max(30, handPxX - anchorX);
ctx.beginPath();
ctx.moveTo(anchorX, anchorY);
const segs = 18;
for (let i = 1; i <= segs; i++) {
const t = i / segs;
const px = anchorX + spanX * t;
const flutter = Math.sin(cloth.wavePhase - t * 9) * (1 - t) * 9 * Math.sin(t * Math.PI);
const sag = Math.sin(t * Math.PI) * 14;
const py = anchorY + (handPxY - anchorY) * t + flutter + sag;
ctx.lineTo(px, py);
}
for (let i = segs; i >= 0; i--) {
const t = i / segs;
const px = anchorX + spanX * t;
const flutter = Math.sin(cloth.wavePhase - t * 9 + 0.5) * (1 - t) * 9 * Math.sin(t * Math.PI);
const sag = Math.sin(t * Math.PI) * 14;
const thick = 6 + t * 12;
const py = anchorY + (handPxY - anchorY) * t + flutter + sag + thick;
ctx.lineTo(px, py);
}
ctx.closePath();
const pullGrad = ctx.createLinearGradient(anchorX, anchorY, handPxX, handPxY);
pullGrad.addColorStop(0, '#b3282e');
pullGrad.addColorStop(0.6, '#8f1d22');
pullGrad.addColorStop(1, '#c43238');
ctx.fillStyle = pullGrad;
ctx.fill();
ctx.strokeStyle = '#e2b354';
ctx.lineWidth = 1.6;
ctx.stroke();
if (handPxX < canvas.width + 80) {
drawHand(handPxX, handPxY + 6, true);
}
}
ctx.restore();
}
function drawHand(x, y, pulling) {
ctx.save();
ctx.translate(x, y);
if (pulling) ctx.rotate(0.12);
// Shirt cuff & dinner jacket sleeve
ctx.fillStyle = '#f2ede4';
ctx.fillRect(14, -11, 14, 22);
ctx.fillStyle = '#1d1d24';
ctx.fillRect(26, -14, 65, 28);
// Knuckles / clenched fist gripping cloth
ctx.fillStyle = '#dfa684';
ctx.beginPath();
ctx.roundRect(-6, -10, 23, 20, 7);
ctx.fill();
ctx.strokeStyle = '#b57b5b';
ctx.lineWidth = 1.2;
ctx.stroke();
ctx.restore();
}
function drawBody(b) {
const px = wx(b.x);
const py = wy(b.y);
const s = PIXELS_PER_FOOT;
// Soft contact shadow on tabletop or floor
const onTableRange = b.x >= -0.1 && b.x <= TABLE_LEN + 0.1 && b.y > -0.2;
const shadowSurfaceY = onTableRange
? wy((!cloth.gone && b.x >= cloth.tailX) ? CLOTH_THICK : 0)
: wy(FLOOR_Y);
const heightAbove = Math.max(0, (shadowSurfaceY - py) / s);
const alpha = Math.max(0.05, 0.36 - heightAbove * 0.18);
ctx.fillStyle = `rgba(0, 0, 0, ${alpha.toFixed(2)})`;
ctx.beginPath();
ctx.ellipse(px, shadowSurfaceY + 2, b.spec.halfW * s * 0.95, 4.5, 0, 0, Math.PI * 2);
ctx.fill();
ctx.save();
ctx.translate(px, py);
// Canvas y points down while world y points up, so positive CCW world angle is -angle in canvas
ctx.rotate(-b.angle);
const cmPx = b.spec.cmY * s; // Distance from CM down to base (base is at y = +cmPx in local canvas coords)
const id = b.spec.id;
if (id === 'glass') {
const baseW = b.spec.baseHalf * s;
const topY = -(b.spec.height - b.spec.cmY) * s;
const bowlBottomY = cmPx - 0.31 * s;
// Wine inside bowl (clipped to goblet interior)
ctx.save();
ctx.beginPath();
ctx.moveTo(-0.12 * s, bowlBottomY - 6);
ctx.bezierCurveTo(-0.14 * s, topY + 18, -0.105 * s, topY + 2, -0.105 * s, topY + 2);
ctx.lineTo(0.105 * s, topY + 2);
ctx.bezierCurveTo(0.105 * s, topY + 2, 0.14 * s, topY + 18, 0.12 * s, bowlBottomY - 6);
ctx.quadraticCurveTo(0, bowlBottomY + 8, -0.12 * s, bowlBottomY - 6);
ctx.clip();
const fillLevel = (bowlBottomY - 22) + b.spilled * 18;
ctx.save();
ctx.translate(0, fillLevel);
ctx.rotate(-b.liquidAngle);
ctx.fillStyle = '#7c1626';
ctx.fillRect(-45, 0, 90, 65);
ctx.fillStyle = '#a8283b';
ctx.fillRect(-45, -2, 90, 4);
ctx.restore();
ctx.restore();
// Crystal stem & foot
ctx.strokeStyle = 'rgba(225, 240, 250, 0.78)';
ctx.fillStyle = 'rgba(215, 236, 248, 0.22)';
ctx.lineWidth = 2.2;
// Bowl outline
ctx.beginPath();
ctx.moveTo(-0.108 * s, topY);
ctx.bezierCurveTo(-0.14 * s, topY + 24, -0.12 * s, bowlBottomY, 0, bowlBottomY + 4);
ctx.bezierCurveTo(0.12 * s, bowlBottomY, 0.14 * s, topY + 24, 0.108 * s, topY);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Stem
ctx.fillStyle = 'rgba(225, 240, 250, 0.55)';
ctx.fillRect(-2.4, bowlBottomY + 3, 4.8, cmPx - bowlBottomY - 6);
// Base foot
ctx.beginPath();
ctx.moveTo(-baseW, cmPx);
ctx.quadraticCurveTo(0, cmPx - 6, baseW, cmPx);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Specular glass streak
ctx.strokeStyle = 'rgba(255,255,255,0.65)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(-0.08 * s, topY + 6);
ctx.quadraticCurveTo(-0.095 * s, topY + 24, -0.06 * s, bowlBottomY - 6);
ctx.stroke();
} else if (id === 'plate') {
const baseW = b.spec.baseHalf * s;
const rimW = b.spec.halfW * s;
const topY = -(b.spec.height - b.spec.cmY) * s;
ctx.fillStyle = '#f5efe6';
ctx.strokeStyle = '#c8bca9';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(-baseW, cmPx);
ctx.lineTo(baseW, cmPx);
ctx.lineTo(rimW, topY + 3);
ctx.lineTo(rimW - 5, topY);
ctx.lineTo(-rimW + 5, topY);
ctx.lineTo(-rimW, topY + 3);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Gold rim stripe
ctx.strokeStyle = '#d4a346';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-rimW + 3, topY + 2.5);
ctx.lineTo(rimW - 3, topY + 2.5);
ctx.stroke();
} else if (id === 'candle') {
const baseW = b.spec.baseHalf * s;
const holderTopY = cmPx - 0.54 * s;
const candleTopY = -(b.spec.height - b.spec.cmY) * s;
// Taper wax candle
const waxGrad = ctx.createLinearGradient(-7, 0, 7, 0);
waxGrad.addColorStop(0, '#e6dcc8');
waxGrad.addColorStop(0.45, '#fffdf7');
waxGrad.addColorStop(1, '#cfc2aa');
ctx.fillStyle = waxGrad;
ctx.beginPath();
ctx.moveTo(-6.5, holderTopY + 3);
ctx.lineTo(6.5, holderTopY + 3);
ctx.lineTo(5, candleTopY);
ctx.lineTo(-5, candleTopY);
ctx.closePath();
ctx.fill();
// Brass candlestick body
const brassGrad = ctx.createLinearGradient(-baseW, 0, baseW, 0);
brassGrad.addColorStop(0, '#7c5a1e');
brassGrad.addColorStop(0.35, '#f2cf74');
brassGrad.addColorStop(0.65, '#cfa03c');
brassGrad.addColorStop(1, '#664815');
ctx.fillStyle = brassGrad;
// Pedestal foot + turned column + drip cup
ctx.beginPath();
ctx.moveTo(-baseW, cmPx);
ctx.quadraticCurveTo(-baseW * 0.9, cmPx - 9, -7, cmPx - 14);
ctx.lineTo(-4.5, holderTopY + 10);
ctx.lineTo(-12, holderTopY + 5);
ctx.lineTo(-12, holderTopY);
ctx.lineTo(12, holderTopY);
ctx.lineTo(12, holderTopY + 5);
ctx.lineTo(4.5, holderTopY + 10);
ctx.lineTo(7, cmPx - 14);
ctx.quadraticCurveTo(baseW * 0.9, cmPx - 9, baseW, cmPx);
ctx.closePath();
ctx.fill();
// Wick & flickering flame
ctx.strokeStyle = '#241a14';
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(0, candleTopY);
ctx.lineTo(0, candleTopY - 5);
ctx.stroke();
if (!b.flameOut) {
ctx.save();
ctx.translate(0, candleTopY - 5);
ctx.rotate(b.angle + b.flameLean); // Flame stays mostly vertical relative to room + wind lean
const glow = ctx.createRadialGradient(0, -8, 1, 0, -8, 26);
glow.addColorStop(0, 'rgba(255, 215, 110, 0.55)');
glow.addColorStop(1, 'rgba(255, 150, 30, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(0, -8, 26, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ff9d2e';
ctx.beginPath();
ctx.moveTo(0, 1);
ctx.quadraticCurveTo(-6.5, -7, 0, -19);
ctx.quadraticCurveTo(6.5, -7, 0, 1);
ctx.fill();
ctx.fillStyle = '#fff7cc';
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(-3, -5, 0, -11);
ctx.quadraticCurveTo(3, -5, 0, 0);
ctx.fill();
ctx.restore();
}
} else if (id === 'bowl') {
const baseW = b.spec.baseHalf * s;
const rimW = b.spec.halfW * s;
const topY = -(b.spec.height - b.spec.cmY) * s;
// Soup slosh peeking slightly at rim when tilted
ctx.save();
ctx.beginPath();
ctx.moveTo(-baseW, cmPx - 3);
ctx.quadraticCurveTo(-rimW, cmPx - 8, -rimW + 2, topY + 2);
ctx.lineTo(rimW - 2, topY + 2);
ctx.quadraticCurveTo(rimW, cmPx - 8, baseW, cmPx - 3);
ctx.clip();
ctx.save();
ctx.translate(0, topY + 9 + b.spilled * 10);
ctx.rotate(-b.liquidAngle);
ctx.fillStyle = '#d9822b';
ctx.fillRect(-60, 0, 120, 45);
ctx.restore();
ctx.restore();
// Glazed porcelain bowl exterior
const bowlGrad = ctx.createLinearGradient(-rimW, 0, rimW, 0);
bowlGrad.addColorStop(0, '#d9cfc1');
bowlGrad.addColorStop(0.35, '#fbf7f0');
bowlGrad.addColorStop(1, '#c4b8a6');
ctx.fillStyle = bowlGrad;
ctx.strokeStyle = '#a89b88';
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(-baseW, cmPx);
ctx.lineTo(baseW, cmPx);
ctx.lineTo(baseW + 2, cmPx - 5);
ctx.quadraticCurveTo(rimW + 2, cmPx - 12, rimW, topY);
ctx.lineTo(-rimW, topY);
ctx.quadraticCurveTo(-rimW - 2, cmPx - 12, -baseW - 2, cmPx - 5);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Cobalt & gold decorative band
ctx.fillStyle = '#2d4b73';
ctx.fillRect(-rimW + 2, topY + 5, (rimW - 2) * 2, 4.5);
ctx.fillStyle = '#d9ac4a';
ctx.fillRect(-rimW + 1, topY + 3.5, (rimW - 1) * 2, 1.5);
} else if (id === 'carafe') {
const baseW = b.spec.baseHalf * s;
const bellyW = 0.18 * s;
const neckW = 0.075 * s;
const topY = -(b.spec.height - b.spec.cmY) * s;
const bellyY = cmPx - 0.26 * s;
const neckY = topY + 0.20 * s;
const makeCarafePath = () => {
ctx.beginPath();
ctx.moveTo(-baseW, cmPx);
ctx.lineTo(baseW, cmPx);
ctx.bezierCurveTo(bellyW + 5, cmPx - 14, bellyW + 4, bellyY, neckW, neckY);
ctx.quadraticCurveTo(neckW - 2, topY + 12, neckW + 4, topY);
ctx.lineTo(-neckW - 4, topY);
ctx.quadraticCurveTo(-neckW + 2, topY + 12, -neckW, neckY);
ctx.bezierCurveTo(-bellyW - 4, bellyY, -bellyW - 5, cmPx - 14, -baseW, cmPx);
ctx.closePath();
};
// Water inside carafe
ctx.save();
makeCarafePath();
ctx.clip();
ctx.save();
ctx.translate(0, bellyY - 16 + b.spilled * 22);
ctx.rotate(-b.liquidAngle);
const waterGrad = ctx.createLinearGradient(0, 0, 0, 80);
waterGrad.addColorStop(0, 'rgba(165, 222, 248, 0.65)');
waterGrad.addColorStop(1, 'rgba(95, 168, 212, 0.72)');
ctx.fillStyle = waterGrad;
ctx.fillRect(-65, 0, 130, 95);
ctx.fillStyle = 'rgba(235, 250, 255, 0.8)';
ctx.fillRect(-65, -2, 130, 3);
ctx.restore();
ctx.restore();
// Glass carafe shell
ctx.fillStyle = 'rgba(220, 240, 252, 0.18)';
ctx.strokeStyle = 'rgba(225, 242, 255, 0.78)';
ctx.lineWidth = 2.2;
makeCarafePath();
ctx.fill();
ctx.stroke();
// Glass highlight curve
ctx.strokeStyle = 'rgba(255, 255, 255, 0.62)';
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.moveTo(-neckW + 4, topY + 10);
ctx.bezierCurveTo(-neckW + 2, neckY, -bellyW + 6, bellyY, -baseW + 6, cmPx - 8);
ctx.stroke();
} else if (id === 'salt') {
const baseW = b.spec.baseHalf * s;
const topY = -(b.spec.height - b.spec.cmY) * s;
const capY = topY + 8;
// Ribbed glass body filled with salt
ctx.fillStyle = '#f4f1eb';
ctx.strokeStyle = '#b7c4cc';
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(-baseW, cmPx);
ctx.lineTo(baseW, cmPx);
ctx.lineTo(baseW + 1.5, capY);
ctx.lineTo(-baseW - 1.5, capY);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Silver dome cap
const capGrad = ctx.createLinearGradient(-baseW, 0, baseW, 0);
capGrad.addColorStop(0, '#7f878c');
capGrad.addColorStop(0.45, '#eef4f7');
capGrad.addColorStop(1, '#687075');
ctx.fillStyle = capGrad;
ctx.beginPath();
ctx.moveTo(-baseW - 1, capY);
ctx.quadraticCurveTo(0, topY - 5, baseW + 1, capY);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
function drawParticles() {
for (const p of droplets) {
ctx.fillStyle = p.color;
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
ctx.beginPath();
ctx.arc(wx(p.x), wy(p.y), 3.1, 0, Math.PI * 2);
ctx.fill();
}
for (const p of sparks) {
ctx.fillStyle = p.color;
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
ctx.fillRect(wx(p.x) - p.size * 0.5, wy(p.y) - p.size * 0.5, p.size, p.size);
}
ctx.globalAlpha = 1;
}
function drawHUD() {
if (state === 'pulling' && !cloth.gone) {
ctx.save();
ctx.font = 'bold 14px Courier New, monospace';
ctx.fillStyle = 'rgba(245, 205, 121, 0.9)';
ctx.fillText(`Cloth velocity: ${cloth.vx.toFixed(1)} ft/s`, wx(TABLE_LEN) - 90, wy(0) + 56);
ctx.restore();
}
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawBackground();
drawTable();
drawTablecloth();
// Draw dinnerware
for (let i = 0; i < bodies.length; i++) {
drawBody(bodies[i]);
}
drawParticles();
drawHUD();
}
// ===================== UI & HASH ROUTING =====================
function setPullSpeed(val) {
const num = Number(val);
if (!Number.isFinite(num) || num <= 0) return;
pullTargetSpeed = num;
// Expand slider bounds smoothly if URL hash requests an extreme speed
if (num < Number(speedRange.min)) speedRange.min = String(Math.max(0.1, Math.floor(num * 10) / 10));
if (num > Number(speedRange.max)) speedRange.max = String(Math.ceil(num));
speedRange.value = String(num);
speedVal.textContent = `${num.toFixed(1)} ft/s`;
}
speedRange.addEventListener('input', () => {
setPullSpeed(parseFloat(speedRange.value));
});
yankBtn.addEventListener('click', startYank);
resetBtn.addEventListener('click', resetScene);
function checkHashSpeed() {
const raw = window.location.hash ? window.location.hash.slice(1) : '';
if (!raw) return;
const match = raw.match(/(?:^|&)speed=([+-]?(?:\d+\.?\d*|\.\d+))/i);
if (match) {
const spd = Math.max(0.1, parseFloat(match[1]));
resetScene();
setPullSpeed(spd);
autoPullTimeout = setTimeout(() => {
autoPullTimeout = null;
startYank();
}, 1000);
}
}
window.addEventListener('hashchange', checkHashSpeed);
// Main animation loop with fixed-cap timestep
let lastTS = performance.now();
function frame(now) {
const rawDt = (now - lastTS) / 1000;
lastTS = now;
const dt = Math.min(0.033, Math.max(0.001, rawDt));
stepPhysics(dt);
render();
requestAnimationFrame(frame);
}
resetScene();
setPullSpeed(9.5);
checkHashSpeed();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Each object is simulated as a 2D rigid convex polygon with mass, rotational inertia, and no speed-keyed win/loss branches: normal and tangential impulses at every base vertex come from Coulomb static/kinetic friction against either the moving cloth ($\mu_k \approx 0.22\text{–}0.26$) or the bare oak table ($\mu_k \approx 0.28\text{–}0.32$), while convex-hull Separating Axis Theorem impulses resolve chain-reaction collisions when sliding items rear-end their neighbors. Where it departs from a real dining room—and where I added one explicit nudge—is the cloth itself: rather than simulating a deformable catenary sheet that bunches under tilted rims, the cloth on the table is a flat horizontal strip with a $15\text{ ms}$ wrist-acceleration ramp and a discrete stitched-hem impulse (b.hemHit) that delivers a small velocity kick scaled by dwell time ($1/V$) as the left edge passes beneath each base, plus uprightness is judged by a fixed $|\theta| < 0.32\text{ rad}$ ($18^\circ$) threshold once linear and angular speeds drop below $0.045\text{ ft/s}$ and $0.075\text{ rad/s}$ for $0.38\text{ s}$. The trick fails in the classic Uncle Arthur ways: pull around $3\text{–}6\text{ ft/s}$ and the tall, narrow brass candlestick ($h_{\text{cm}} = 0.46\text{ ft}$, $r_b = 0.145\text{ ft}$) picks up enough forward momentum on the cloth that catching its leading edge on the bare oak snaps it face-first into the soup bowl; pull around $8\text{–}12\text{ ft/s}$ and the leftmost wine glass and dinner plate survive cleanly, while the rightmost salt cellar and water carafe—which sit under $3.3\text{–}3.8\text{ ft}$ of passing cloth—accumulate just enough slip velocity to skate off the right bullnose corner onto the hardwood floor.