27:18Gemini 4 Hydrogen: Cheaper Tokens, More Expensive Answers?
Nov 13, 2026 · YouTube
36:22Fable 5.2 robbing me blind, plus: Nex AGI's N2.6 vs GLM 5.3 Flash
Nov 11, 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 dropped a garden hose with the water on full.
Build me a single HTML page: a garden hose lying on a lawn, the spigot on, and the nozzle released.
#psi=40 sets the pressure to 40 psi with the nozzle free on load. Any number works.data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.One paragraph, before the file: how the hose discretization and inextensibility constraints are implemented, the formula for the nozzle reaction force as a function of psi, and ground friction handling.
The pressure in psi at which the hose transitions from stationary or settling to continuous whipping, as one number, derived from your model parameters.
The single fenced html block.
Where the motion looks like a real hose and where it does not. Name any artificial damping, injected noise or scripted oscillation. Everyone has watched a hose get away from somebody; say what yours gets wrong before I see it.
The hose is 41 Verlet particles joined by 40 segments of 0.175 m (7 m total), carrying water-filled mass at 0.42 kg/m, with the brass nozzle lumped as 0.35 kg on the last particle and a 0.16 kg coupling on the second-to-last. Inextensibility is enforced by position-based dynamics: 4 substeps of dt = 1/240 s per frame, each running 16 Gauss–Seidel sweeps of invMass-weighted projections onto |pᵢ₊₁−pᵢ| = 0.175 m (≈64 sweeps per frame, residual stretch well under 1%), plus a one-sided second-neighbour floor (chord ≥ 0.26 m, so the interior angle can't close past ~96° — no hairpins), a weak contact-aware straightening term for bending stiffness, and segment–segment self-collision at the 31 mm hose radius. Everything integrates in metres, y-up, ground at y = 0. The nozzle reaction is jet momentum flux: F = 2·Cd²·A·ΔP with discharge coefficient Cd = 0.95, a 7 mm orifice A = 3.85×10⁻⁵ m², and ΔP = ψ·6894.76 Pa — i.e. F ≈ 0.48·ψ newtons (≈4.3 lbf at 40 psi) — applied at the nozzle particle along −t̂, where t̂ is the current tail tangent (opposite the jet). The same pressure gives jet exit speed v = Cd√(2ΔP/ρ) ≈ 3.53·√ψ m/s for the particle water, and commanded pressure lags the slider through a 0.3 s first-order line. Ground contact projects penetrating nodes to y = 0 with an inelastic normal (12% restitution) and Coulomb friction as a per-substep tangential impulse capped at μ·g·dt with μ = 0.9 for grass — a resting node under any net tangential force below μ·m·g never accumulates speed, which is what makes the hose actually settle instead of drift.
≈ 20 psi. Setting jet thrust equal to the Coulomb drag the free end must break — F = μ·λ·L·g with μ = 0.9, λ = 0.42 kg/m, and L ≈ 2.6 m (about 15 nodes: the length of hose the tail can realistically drag while the spigot anchor and the stationary pile hold the rest) — gives F* ≈ 9.6 N, so ψ* = 9.6 / 0.479 ≈ 20.1 psi. Below it, the sliding section is too short for the rotating thrust vector to keep feeding momentum and friction wins; above it, the whip self-sustains.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Runaway Hose</title>
<style>
:root{ --ink:#f0e8d6; --dim:#c9bfa4; --brass:#c9a35c; --panel:rgba(16,22,13,.78); }
html,body{margin:0;height:100%;overflow:hidden;background:#22301c;user-select:none;-webkit-user-select:none}
canvas{display:block;position:fixed;inset:0;touch-action:none}
.plate{position:fixed;top:18px;left:22px;color:var(--ink);pointer-events:none}
.plate h1{font:small-caps 400 26px/1 Georgia,'Times New Roman',serif;letter-spacing:.14em;margin:0 0 6px}
.plate p{font:12px/1.5 ui-monospace,Menlo,Consolas,monospace;color:var(--dim);margin:0;letter-spacing:.04em}
.hint{position:fixed;top:18px;right:22px;color:var(--dim);font:12px/1.55 ui-monospace,Menlo,Consolas,monospace;
pointer-events:none;text-align:right;letter-spacing:.03em}
.panel{position:fixed;right:20px;bottom:16px;display:flex;gap:16px;align-items:center;padding:12px 16px;
background:var(--panel);border:1px solid rgba(201,163,92,.4);border-radius:12px;color:var(--ink);
backdrop-filter:blur(3px)}
.reads{display:flex;flex-direction:column;gap:2px;font:12px/1.45 ui-monospace,Menlo,Consolas,monospace;min-width:172px}
.reads .big{font-size:22px}
.reads .big small{font-size:12px;color:var(--dim)}
.reads .row{display:flex;justify-content:space-between;color:var(--dim)}
.reads .row b{color:var(--ink);font-weight:400}
#status.whip{color:#e8a13c}
#status.calm{color:#9dc37e}
input[type=range]{-webkit-appearance:none;appearance:none;width:190px;height:20px;background:transparent;margin:6px 0 2px}
input[type=range]::-webkit-slider-runnable-track{height:3px;background:rgba(240,232,214,.35);border-radius:2px}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:17px;height:17px;border-radius:50%;
background:var(--brass);border:1px solid #6e5420;margin-top:-7px;cursor:grab}
input[type=range]::-moz-range-track{height:3px;background:rgba(240,232,214,.35);border-radius:2px}
input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--brass);
border:1px solid #6e5420;cursor:grab}
.lbl{font:10px/1 ui-monospace,Menlo,Consolas,monospace;color:var(--dim);display:flex;justify-content:space-between;letter-spacing:.06em}
#snd{margin-top:8px;font:11px ui-monospace,Menlo,Consolas,monospace;color:var(--dim);background:none;
border:1px solid rgba(201,163,92,.4);border-radius:6px;padding:4px 8px;cursor:pointer}
#snd:hover{color:var(--ink)}
@media (max-width:640px){.plate h1{font-size:19px}.panel{gap:10px;padding:10px}
input[type=range]{width:120px}.reads{min-width:140px}}
</style>
</head>
<body>
<canvas id="scene"></canvas>
<div class="plate">
<h1>The Runaway Hose</h1>
<p>7 m of 5/8″ hose · spigot open · nozzle loose</p>
</div>
<div class="hint">drag any point of the hose to pin it<br>release to let it go</div>
<div class="panel">
<svg id="gauge" width="118" height="112" viewBox="0 0 120 114"></svg>
<div class="reads">
<div class="big"><span id="psiR">40.0</span><small> psi</small></div>
<div class="row"><span>thrust</span><b id="thrR">4.3 lbf</b></div>
<div class="row"><span>flow</span><b id="flowR">13.6 gpm</b></div>
<div class="row"><span>tail</span><b id="status" class="calm">settled</b></div>
<input id="slider" type="range" min="0" max="60" step="0.5" value="40">
<div class="lbl"><span>0</span><span>PRESSURE</span><span>60 · full</span></div>
<button id="snd">sound: off</button>
</div>
</div>
<script>
'use strict';
const $=id=>document.getElementById(id);
/* ================= constants (SI, y-up, ground at y=0) ================= */
const N=41, SEGS=40, L=7.0, D0=L/SEGS; // 41 particles, 0.175 m segments
const LAM=0.42, G=9.81, MU=0.9, RHO=1000; // kg/m water-filled hose, grass friction
const CD=0.95, A_OR=Math.PI*0.0035*0.0035; // 7 mm nozzle orifice
const F_PER_PSI=2*CD*CD*A_OR*6894.76; // thrust N per psi (~0.479)
const R_HOSE=0.031, KINK=0.74; // hose radius, kink floor (angle >= ~96 deg)
const BEND_AIR=0.05, BEND_GND=0.0025; // bending: lively in air, plastic on grass
const CAIR=0.25, DAMP=0.9995, MAXD=0.2; // air drag 1/s, Verlet damping, safety clamp
const SUB_DT=1/240, INV_DT=240, ITER=16;
const X_PIPE=1.0, ATT_X=1.17, ATT_Y=0.72; // standpipe and hose attachment
const WORLD_W=11, GROUND_FRAC=0.74;
/* ================= canvas ================= */
const cv=$('scene'), ctx=cv.getContext('2d');
let W=0,H=0,dpr=1,S=100,groundY=0;
const bg=document.createElement('canvas');
/* ================= hose state ================= */
const px=new Float64Array(N),py=new Float64Array(N),
ox=new Float64Array(N),oy=new Float64Array(N),
im=new Float64Array(N),baseM=new Float64Array(N);
let psiTarget=40, psiNow=40, wheelAng=0;
let pinned=-1, mx=0,my=0, mvx=0,mvy=0, lastMT=performance.now();
function initHose(){
for(let i=0;i<N;i++){
let x,y;
if(i===0){x=ATT_X;y=ATT_Y;}
else if(i<4){x=ATT_X+0.05*i; y=ATT_Y-0.24*i;} // drop from the spigot
else{const t=(i-3)/(N-4); x=1.32+t*6.1; // loose S-lay on the lawn
y=Math.max(0.02, 0.13+0.17*Math.sin(t*7.0));}
px[i]=ox[i]=x; py[i]=oy[i]=y;
baseM[i]=LAM*D0;
}
baseM[N-1]=0.35; baseM[N-2]=0.16; // brass nozzle + coupling
for(let i=0;i<N;i++) im[i]= i===0?0:1/baseM[i]; // node 0 welded to the spigot
}
/* ================= closest point between two segments ================= */
const cso={s:0,t:0,px_:0,py_:0,qx_:0,qy_:0};
function closestSeg(ax,ay,bx,by,cx,cy,dx,dy,o){
const d1x=bx-ax,d1y=by-ay,d2x=dx-cx,d2y=dy-cy,rx=ax-cx,ry=ay-cy;
const a=d1x*d1x+d1y*d1y, e=d2x*d2x+d2y*d2y;
const b=d1x*d2x+d1y*d2y, c=d1x*rx+d1y*ry, f=d2x*rx+d2y*ry;
const den=a*e-b*b;
let s=den>1e-12?Math.min(1,Math.max(0,(b*f-c*e)/den)):0;
let t=(b*s+f)/e;
if(t<0){t=0;s=Math.min(1,Math.max(0,-c/a));}
else if(t>1){t=1;s=Math.min(1,Math.max(0,(b-c)/a));}
o.s=s;o.t=t;
o.px_=ax+d1x*s; o.py_=ay+d1y*s;
o.qx_=cx+d2x*t; o.qy_=cy+d2y*t;
}
/* ================= one physics substep ================= */
function substep(dt){
/* nozzle reaction F = 2*Cd^2*A*dP along -tangent (opposite the jet) */
let ux=px[N-1]-px[N-2], uy=py[N-1]-py[N-2];
const ul=Math.hypot(ux,uy)||1e-9; ux/=ul; uy/=ul;
const F=F_PER_PSI*psiNow, tx=-ux*F, ty=-uy*F;
/* Verlet integration with gravity, air drag, thrust on the nozzle */
for(let i=1;i<N;i++){
if(im[i]===0) continue;
const w=im[i];
let ax=-CAIR*(px[i]-ox[i])*INV_DT,
ay=-G -CAIR*(py[i]-oy[i])*INV_DT;
if(i===N-1){ax+=tx*w; ay+=ty*w;}
let nx=px[i]+(px[i]-ox[i])*DAMP+ax*dt*dt;
let ny=py[i]+(py[i]-oy[i])*DAMP+ay*dt*dt;
const vx=nx-ox[i], vy=ny-oy[i], vm=vx*vx+vy*vy;
if(vm>MAXD*MAXD){const k=MAXD/Math.sqrt(vm); nx=ox[i]+vx*k; ny=oy[i]+vy*k;}
ox[i]=px[i]; oy[i]=py[i]; px[i]=nx; py[i]=ny;
}
/* pinned particle rides the pointer, keeping its throw velocity */
if(pinned>=0){
px[pinned]=mx; py[pinned]=Math.max(my,0.02);
ox[pinned]=px[pinned]-mvx*dt; oy[pinned]=py[pinned]-mvy*dt;
}
/* inextensibility + kink floor */
const minD=2*D0*KINK, min2=minD*minD;
for(let it=0;it<ITER;it++){
for(let s2=0;s2<SEGS;s2++){
const a=s2,b=s2+1, wa=im[a],wb=im[b],ws=wa+wb;
if(ws===0) continue;
const dx=px[b]-px[a], dy=py[b]-py[a];
const dist=Math.hypot(dx,dy)||1e-9, c=(dist-D0)/dist/ws;
px[a]+=dx*c*wa; py[a]+=dy*c*wa;
px[b]-=dx*c*wb; py[b]-=dy*c*wb;
}
for(let i=1;i<N-1;i++){
const a=i-1,b=i+1, wa=im[a],wb=im[b],ws=wa+wb;
if(ws===0) continue;
const dx=px[b]-px[a], dy=py[b]-py[a], d2=dx*dx+dy*dy;
if(d2<min2){
const dist=Math.sqrt(d2)||1e-9, c=(minD-dist)/dist/ws;
px[a]-=dx*c*wa; py[a]-=dy*c*wa;
px[b]+=dx*c*wb; py[b]+=dy*c*wb;
}
}
px[0]=ATT_X; py[0]=ATT_Y;
if(pinned>=0){px[pinned]=mx; py[pinned]=Math.max(my,0.02);}
}
/* bending: strong in the air, nearly plastic against the grass */
for(let i=1;i<N-1;i++){
if(im[i]===0) continue;
const bb=py[i]<0.002?BEND_GND:BEND_AIR;
px[i]+=((px[i-1]+px[i+1])*0.5-px[i])*bb;
py[i]+=((py[i-1]+py[i+1])*0.5-py[i])*bb;
}
/* keep out of the standpipe */
const rr=R_HOSE+0.03;
for(let i=1;i<N;i++){
if(i===pinned) continue;
if(py[i]<1.0&&py[i]>-0.1){
const dx=px[i]-X_PIPE;
if(Math.abs(dx)<rr) px[i]=X_PIPE+(dx>=0?rr:-rr);
}
}
/* ground: project, inelastic normal, Coulomb tangential impulse mu*g*dt */
const drop=MU*G*dt*dt;
for(let i=1;i<N;i++){
if(py[i]<0){
py[i]=0;
const dy=py[i]-oy[i];
if(dy<0) oy[i]=py[i]+0.12*dy; // small restitution
const dx=px[i]-ox[i];
if(Math.abs(dx)<=drop) ox[i]=px[i]; // static-like: never builds speed
else ox[i]=px[i]-Math.sign(dx)*drop; // kinetic: mu*g deceleration
}
}
/* self-collision, one relaxed pass over segment pairs */
for(let i=0;i<SEGS;i++){
const ax=px[i],ay=py[i],bx=px[i+1],by=py[i+1];
const lox=Math.min(ax,bx)-2*R_HOSE, hix=Math.max(ax,bx)+2*R_HOSE,
loy=Math.min(ay,by)-2*R_HOSE, hiy=Math.max(ay,by)+2*R_HOSE;
for(let j=i+2;j<SEGS;j++){
const cx=px[j],cy=py[j],dx2=px[j+1],dy2=py[j+1];
if(Math.max(cx,dx2)<lox||Math.min(cx,dx2)>hix||
Math.max(cy,dy2)<loy||Math.min(cy,dy2)>hiy) continue;
closestSeg(ax,ay,bx,by,cx,cy,dx2,dy2,cso);
const ddx=cso.px_-cso.qx_, ddy=cso.py_-cso.qy_;
const dd=Math.hypot(ddx,ddy);
if(dd<2*R_HOSE&&dd>1e-9){
const nx2=ddx/dd, ny2=ddy/dd, push=2*R_HOSE-dd;
const w1=(1-cso.s)*im[i], w2=cso.s*im[i+1],
w3=(1-cso.t)*im[j], w4=cso.t*im[j+1];
const ws=w1+w2+w3+w4;
if(ws>0){
const k=push/ws;
px[i] +=nx2*k*w1; py[i] +=ny2*k*w1;
px[i+1]+=nx2*k*w2; py[i+1]+=ny2*k*w2;
px[j] -=nx2*k*w3; py[j] -=ny2*k*w3;
px[j+1]-=nx2*k*w4; py[j+1]-=ny2*k*w4;
}
}
}
}
}
/* ================= water: droplets, stains, splashes ================= */
const PMAX=900;
const ppx=new Float64Array(PMAX),ppy=new Float64Array(PMAX),
pvx=new Float64Array(PMAX),pvy=new Float64Array(PMAX),
page=new Float64Array(PMAX).fill(-1);
let pHead=0, spawnAcc=0, jux=1, juy=0;
const stains=[], splashes=[];
function vExit(){return CD*Math.sqrt(2*Math.max(0,psiNow)*6894.76/RHO);}
function spawnWater(dt){
const vE=vExit();
let ux=px[N-1]-px[N-2], uy=py[N-1]-py[N-2];
const ul=Math.hypot(ux,uy)||1; ux/=ul; uy/=ul;
jux+=(ux-jux)*0.35; juy+=(uy-juy)*0.35; // render-only smoothing of jet dir
const jl=Math.hypot(jux,juy)||1; jux/=jl; juy/=jl;
spawnAcc+=380*RHO*A_OR*vE*dt;
if(spawnAcc>60) spawnAcc=60;
if(vE<0.5){spawnAcc=0;return;}
const tipx=px[N-1]+ux*0.16, tipy=py[N-1]+uy*0.16;
const tvx=(px[N-1]-ox[N-1])*INV_DT, tvy=(py[N-1]-oy[N-1])*INV_DT;
const perx=-juy, pery=jux;
while(spawnAcc>=1){
spawnAcc--;
const sp=vE*(0.94+0.12*Math.random());
const lat=(Math.random()-0.5)*vE*0.07; // spray spread
const i=pHead; pHead=(pHead+1)%PMAX;
ppx[i]=tipx+jux*(0.03+0.3*Math.random())+perx*(Math.random()-0.5)*0.03;
ppy[i]=Math.max(0.015,tipy+juy*(0.03+0.3*Math.random())+pery*(Math.random()-0.5)*0.03);
pvx[i]=jux*sp+perx*lat+tvx*0.5;
pvy[i]=juy*sp+pery*lat+tvy*0.5;
page[i]=0;
}
}
function addStain(x,r){
if(stains.length>240) stains.shift();
stains.push({x,r,a:0.14+0.2*Math.random(),age:0});
}
function addSplash(x,y,vx,vy){
if(splashes.length>160) splashes.shift();
splashes.push({x,y,vx,vy,age:0,life:0.25+Math.random()*0.3});
}
function updateWater(dt){
const K=0.22; // droplet quadratic drag, 1/m
for(let i=0;i<PMAX;i++){
if(page[i]<0) continue;
let vx=pvx[i], vy=pvy[i]-G*dt;
const sp=Math.hypot(vx,vy);
vx-=K*sp*vx*dt; vy-=K*sp*vy*dt;
const x=ppx[i]+vx*dt, y=ppy[i]+vy*dt;
if(y<=0&&vy<0){
page[i]=-1;
addStain(x,0.05+0.09*Math.random()+Math.min(0.12,-vy*0.008));
const n=2+(Math.random()*3|0);
for(let k=0;k<n;k++)
addSplash(x,0.01,(Math.random()-0.5)*2.4,0.4+Math.random()*1.6);
continue;
}
ppx[i]=x; ppy[i]=y; pvx[i]=vx; pvy[i]=vy; page[i]+=dt;
if(page[i]>7||x<-2||x>26||y>45) page[i]=-1;
}
}
function updateSplashes(dt){
for(let i=splashes.length-1;i>=0;i--){
const s=splashes[i];
s.age+=dt; s.vy-=G*dt; s.x+=s.vx*dt; s.y+=s.vy*dt;
if(s.age>s.life||s.y<0) splashes.splice(i,1);
}
}
/* ================= background (pre-rendered) ================= */
function cloud(g,x,y,s){
g.fillStyle='rgba(255,255,255,0.5)';
g.beginPath();
g.ellipse(x,y,46*s,13*s,0,0,7);
g.ellipse(x-30*s,y+5*s,30*s,10*s,0,0,7);
g.ellipse(x+34*s,y+6*s,26*s,9*s,0,0,7);
g.fill();
}
function drawSpigot(g){
const x=X_PIPE*S, gy=groundY, topY=gy-0.85*S;
g.fillStyle='#9a9484'; g.fillRect(x-0.16*S,gy-0.05*S,0.32*S,0.06*S); // pad
g.fillStyle='#788084'; g.fillRect(x-0.028*S,topY,0.056*S,0.85*S); // pipe
g.fillStyle='#565d61'; g.fillRect(x+0.012*S,topY,0.016*S,0.85*S);
g.fillStyle='#a8b0b4'; g.fillRect(x-0.024*S,topY,0.010*S,0.85*S);
g.fillStyle='#5a6165'; g.fillRect(x-0.05*S,gy-0.10*S,0.10*S,0.03*S); // flange
g.fillStyle='#6f777b'; g.fillRect(x-0.01*S,topY+0.02*S,0.20*S,0.05*S); // tap body
g.fillStyle='#4d5457'; g.fillRect(x-0.01*S,topY+0.055*S,0.20*S,0.015*S);
g.fillStyle='#6f777b'; g.fillRect(x+0.15*S,topY+0.02*S,0.045*S,0.13*S);// spout
g.fillStyle='#24401f'; g.fillRect(x+0.145*S,topY+0.12*S,0.055*S,0.045*S); // collar
}
function buildBackground(){
bg.width=Math.round(W*dpr); bg.height=Math.round(H*dpr);
const g=bg.getContext('2d'); g.setTransform(dpr,0,0,dpr,0,0);
const sky=g.createLinearGradient(0,0,0,groundY);
sky.addColorStop(0,'#aecbda'); sky.addColorStop(0.65,'#d8e2d8'); sky.addColorStop(1,'#f1e3c2');
g.fillStyle=sky; g.fillRect(0,0,W,groundY);
g.fillStyle='rgba(255,246,214,0.18)'; g.beginPath(); g.arc(W*0.16,groundY-H*0.30,H*0.10,0,7); g.fill();
g.fillStyle='rgba(255,246,214,0.85)'; g.beginPath(); g.arc(W*0.16,groundY-H*0.30,H*0.045,0,7); g.fill();
cloud(g,W*0.55,H*0.15,1.0); cloud(g,W*0.82,H*0.23,0.7);
g.fillStyle='#46603a'; // hedge
g.beginPath(); g.moveTo(0,groundY);
for(let x=0;x<=W;x+=14)
g.lineTo(x,groundY-0.32*S-Math.sin(x*0.013)*0.10*S-Math.sin(x*0.037+2)*0.06*S);
g.lineTo(W,groundY); g.closePath(); g.fill();
g.fillStyle='#6d5136'; // fence
g.fillRect(0,groundY-0.42*S,W,0.045*S); g.fillRect(0,groundY-0.24*S,W,0.045*S);
for(let x=0.3*S;x<W;x+=1.15*S) g.fillRect(x,groundY-0.52*S,0.075*S,0.52*S);
g.fillStyle='rgba(241,227,194,0.35)'; g.fillRect(0,groundY-0.62*S,W,0.62*S); // haze
const lawn=g.createLinearGradient(0,groundY,0,H);
lawn.addColorStop(0,'#6a9a49'); lawn.addColorStop(1,'#4a7434');
g.fillStyle=lawn; g.fillRect(0,groundY,W,H-groundY);
for(let k=0;k<6;k+=2){ // mow stripes
g.fillStyle='rgba(255,255,230,0.05)';
g.fillRect(0,groundY+(H-groundY)*k/6,W,(H-groundY)/6);
}
const cols=['#54823a','#63934a','#6fa050','#476f2e','#7fae5c','#3f662a'];
const nB=Math.min(9000,(W*(H-groundY)/140)|0); // blades
for(let k=0;k<nB;k++){
const x=Math.random()*W, y=groundY+Math.random()*(H-groundY);
const h=(2+Math.random()*5)*(0.6+0.6*(y-groundY)/(H-groundY));
g.strokeStyle=cols[(Math.random()*cols.length)|0];
g.globalAlpha=0.5+Math.random()*0.5; g.lineWidth=1;
g.beginPath(); g.moveTo(x,y); g.lineTo(x+(Math.random()-0.5)*2.5,y-h); g.stroke();
}
g.globalAlpha=1;
for(let k=0;k<W/70;k++){ // clover & daisies
const x=Math.random()*W, y=groundY+Math.random()*(H-groundY)*0.9;
const white=Math.random()<0.5;
g.fillStyle=white?'rgba(245,242,225,0.9)':'rgba(240,225,140,0.85)';
for(let p=0;p<4;p++){g.beginPath();g.arc(x+Math.cos(p*1.57)*2.2,y+Math.sin(p*1.57)*1.4,1.3,0,7);g.fill();}
if(white){g.fillStyle='rgba(235,200,80,0.9)';g.beginPath();g.arc(x,y,1,0,7);g.fill();}
}
drawSpigot(g);
}
/* ================= dynamic drawing ================= */
function drawWheel(){
const x=X_PIPE*S, y=groundY-0.93*S, r=0.075*S;
ctx.save(); ctx.translate(x,y); ctx.rotate(wheelAng);
ctx.strokeStyle='#a33327'; ctx.lineWidth=0.022*S;
ctx.beginPath(); ctx.arc(0,0,r,0,7); ctx.stroke();
ctx.beginPath();
for(let k=0;k<3;k++){const a=k*Math.PI*2/3; ctx.moveTo(0,0); ctx.lineTo(Math.cos(a)*r,Math.sin(a)*r);}
ctx.stroke();
ctx.fillStyle='#7c612a'; ctx.beginPath(); ctx.arc(0,0,0.024*S,0,7); ctx.fill();
ctx.restore();
}
function drawStains(){
for(let i=0;i<stains.length;i++){
const st=stains[i], f=1-st.age/26;
ctx.fillStyle='rgba(26,42,20,'+(st.a*f).toFixed(3)+')';
ctx.beginPath();
ctx.ellipse(st.x*S,groundY+0.012*S,st.r*S,st.r*0.22*S,0,0,7);
ctx.fill();
}
}
function drawHose(){
ctx.lineCap='round'; ctx.lineJoin='round';
for(let i=0;i<SEGS;i++){ // per-segment ground shadow
const h=Math.max(0,(py[i]+py[i+1])*0.5);
const a=0.16*Math.exp(-1.1*h);
if(a<0.012) continue;
ctx.globalAlpha=a; ctx.strokeStyle='#141e0c'; ctx.lineWidth=0.075*S;
const off=0.35*h*S;
ctx.beginPath();
ctx.moveTo(px[i]*S+off,groundY+0.02*S);
ctx.lineTo(px[i+1]*S+off,groundY+0.02*S);
ctx.stroke();
}
ctx.globalAlpha=1;
const path=new Path2D(); // smoothed centreline
path.moveTo(px[0]*S,groundY-py[0]*S);
for(let i=1;i<N-1;i++){
const mxp=(px[i]+px[i+1])*0.5*S, myp=groundY-(py[i]+py[i+1])*0.5*S;
path.quadraticCurveTo(px[i]*S,groundY-py[i]*S,mxp,myp);
}
path.lineTo(px[N-1]*S,groundY-py[N-1]*S);
ctx.strokeStyle='#14291b'; ctx.lineWidth=0.078*S; ctx.stroke(path);
ctx.strokeStyle='#2e5b36'; ctx.lineWidth=0.060*S; ctx.stroke(path);
ctx.save(); ctx.translate(-0.006*S,-0.011*S); // sheen
ctx.strokeStyle='rgba(146,190,136,0.38)'; ctx.lineWidth=0.018*S; ctx.stroke(path);
ctx.restore();
ctx.strokeStyle='rgba(224,212,164,0.8)'; ctx.lineWidth=0.011*S; ctx.stroke(path); // stripe
for(let i=6;i<N-3;i+=6){ // couplings
const tx=px[i+1]-px[i-1], ty=py[i+1]-py[i-1], tl=Math.hypot(tx,ty)||1;
const nx2=-ty/tl, ny2=tx/tl, sx=px[i]*S, sy=groundY-py[i]*S;
ctx.strokeStyle='#16301f'; ctx.lineWidth=0.070*S;
ctx.beginPath();
ctx.moveTo(sx-nx2*0.036*S,sy+ny2*0.036*S);
ctx.lineTo(sx+nx2*0.036*S,sy-ny2*0.036*S);
ctx.stroke();
ctx.strokeStyle='rgba(179,144,63,0.9)'; ctx.lineWidth=0.012*S;
ctx.beginPath();
ctx.moveTo(sx-nx2*0.030*S,sy+ny2*0.030*S);
ctx.lineTo(sx+nx2*0.030*S,sy-ny2*0.030*S);
ctx.stroke();
}
}
function drawNozzle(){
const ux=px[N-1]-px[N-2], uy=py[N-1]-py[N-2];
const ang=Math.atan2(-uy,ux), u=S;
ctx.save();
ctx.translate(px[N-1]*S,groundY-py[N-1]*S); ctx.rotate(ang);
ctx.fillStyle='#24401f'; ctx.fillRect(-0.075*u,-0.031*u,0.08*u,0.062*u);
ctx.fillStyle='#b3903f'; ctx.fillRect(0.005*u,-0.026*u,0.10*u,0.052*u);
ctx.fillStyle='#e0c069'; ctx.fillRect(0.005*u,-0.026*u,0.10*u,0.011*u);
ctx.fillStyle='#7c612a'; ctx.fillRect(0.005*u, 0.015*u,0.10*u,0.011*u);
ctx.beginPath();
ctx.moveTo(0.105*u,-0.024*u); ctx.lineTo(0.165*u,-0.010*u);
ctx.lineTo(0.165*u,0.010*u); ctx.lineTo(0.105*u,0.024*u);
ctx.closePath(); ctx.fillStyle='#b3903f'; ctx.fill();
ctx.strokeStyle='#6e5420'; ctx.lineWidth=1; ctx.stroke();
ctx.fillStyle='#7c612a';
ctx.fillRect(0.028*u,-0.058*u,0.015*u,0.032*u);
ctx.beginPath(); ctx.arc(0.0355*u,-0.062*u,0.015*u,0,7); ctx.fill();
ctx.restore();
}
function drawWater(){
ctx.lineCap='round';
ctx.beginPath();
let any=false;
for(let i=0;i<PMAX;i++){
if(page[i]<0) continue;
any=true;
const x=ppx[i]*S, y=groundY-ppy[i]*S;
ctx.moveTo(x,y);
ctx.lineTo(x-pvx[i]*0.011*S, y+pvy[i]*0.011*S);
}
if(!any) return;
ctx.strokeStyle='rgba(200,226,238,0.25)'; ctx.lineWidth=3.4; ctx.stroke();
ctx.strokeStyle='rgba(232,244,250,0.7)'; ctx.lineWidth=1.4; ctx.stroke();
}
function drawSplashes(){
ctx.fillStyle='rgba(225,240,246,0.8)';
for(const s of splashes){
ctx.globalAlpha=Math.max(0,1-s.age/s.life);
ctx.beginPath(); ctx.arc(s.x*S,groundY-s.y*S,1.4,0,7); ctx.fill();
}
ctx.globalAlpha=1;
}
function drawPin(){
if(pinned<0) return;
const x=px[pinned]*S, y=groundY-py[pinned]*S;
ctx.strokeStyle='#c9a35c'; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(x,y,0.07*S,0,7); ctx.stroke();
ctx.fillStyle='#c9a35c';
ctx.beginPath(); ctx.arc(x,y,2.5,0,7); ctx.fill();
}
/* ================= gauge (inline SVG, built once) ================= */
const NS='http://www.w3.org/2000/svg', gauge=$('gauge');
let needle=null;
function el(n,at){const e=document.createElementNS(NS,n);for(const k in at)e.setAttribute(k,at[k]);gauge.appendChild(e);return e;}
function pol(a,r){const t=a*Math.PI/180;return[60+r*Math.sin(t),60-r*Math.cos(t)];}
function buildGauge(){
el('circle',{cx:60,cy:60,r:52,fill:'#191914',stroke:'#8a6d33','stroke-width':2});
el('circle',{cx:60,cy:60,r:48,fill:'#efe6cd',stroke:'#b3903f','stroke-width':3});
const p1=pol(80,39.5), p2=pol(120,39.5);
el('path',{d:'M'+p1[0]+' '+p1[1]+' A39.5 39.5 0 0 1 '+p2[0]+' '+p2[1],
stroke:'#b23a2a','stroke-width':4.5,fill:'none'});
for(let v=0;v<=60;v+=5){
const A=-120+4*v, major=v%10===0;
const q1=pol(A,43), q2=pol(A,major?35:39);
el('line',{x1:q1[0],y1:q1[1],x2:q2[0],y2:q2[1],stroke:'#3a352c','stroke-width':major?2:1});
if(major){
const q3=pol(A,27.5);
const t=el('text',{x:q3[0],y:q3[1]+2.5,'text-anchor':'middle','font-size':'8',
'font-family':'ui-monospace,Menlo,Consolas,monospace',fill:'#3a352c'});
t.textContent=v;
}
}
const lb=el('text',{x:60,y:88,'text-anchor':'middle','font-size':'7','letter-spacing':'2',
'font-family':'ui-monospace,Menlo,Consolas,monospace',fill:'#6b6350'});
lb.textContent='PSI';
needle=el('polygon',{points:'57.6,60 62.4,60 60.7,20 59.3,20',fill:'#b23a2a'});
el('circle',{cx:60,cy:60,r:4,fill:'#6e5420',stroke:'#3f3115','stroke-width':1});
}
/* ================= HUD ================= */
const psiR=$('psiR'), thrR=$('thrR'), flowR=$('flowR'), statusEl=$('status'),
slider=$('slider'), sndBtn=$('snd');
let lastStatus='';
function updateHUD(){
psiR.textContent=psiNow.toFixed(1);
thrR.textContent=(F_PER_PSI*psiNow/4.448).toFixed(1)+' lbf';
flowR.textContent=(A_OR*vExit()*15850.3).toFixed(1)+' gpm';
const st=tailEMA<0.15?'settled':tailEMA<0.8?'twitching':'WHIPPING';
if(st!==lastStatus){
lastStatus=st; statusEl.textContent=st;
statusEl.className=st==='WHIPPING'?'whip':st==='settled'?'calm':'';
}
needle.setAttribute('transform','rotate('+(-120+4*psiNow)+' 60 60)');
}
/* ================= optional synthesized hiss ================= */
let AC=null, gainN=null, filtN=null, soundOn=false;
sndBtn.addEventListener('click',()=>{
if(!AC){
AC=new (window.AudioContext||window.webkitAudioContext)();
const len=2*AC.sampleRate, buf=AC.createBuffer(1,len,AC.sampleRate);
const d=buf.getChannelData(0);
for(let i=0;i<len;i++) d[i]=Math.random()*2-1;
const src=AC.createBufferSource(); src.buffer=buf; src.loop=true;
filtN=AC.createBiquadFilter(); filtN.type='bandpass'; filtN.Q.value=0.6;
gainN=AC.createGain(); gainN.gain.value=0;
src.connect(filtN).connect(gainN).connect(AC.destination);
src.start();
}
soundOn=!soundOn;
sndBtn.textContent='sound: '+(soundOn?'on':'off');
if(soundOn) AC.resume();
});
function updateSound(){
if(!AC||!soundOn) return;
const t=psiNow/60;
const flutter=0.7+0.5*Math.min(1,tailEMA/3);
gainN.gain.setTargetAtTime(Math.min(0.5,0.35*t*t*flutter),AC.currentTime,0.06);
filtN.frequency.value=600+2600*t;
}
/* ================= pointer pinning ================= */
function toWorld(e){return [e.clientX/S,(groundY-e.clientY)/S];}
cv.addEventListener('pointerdown',e=>{
if(e.button>0) return;
const w=toWorld(e);
let best=-1,bd=0.45;
for(let i=1;i<N;i++){
const d=Math.hypot(px[i]-w[0],py[i]-w[1]);
if(d<bd){bd=d;best=i;}
}
if(best>=0){
pinned=best; im[best]=0;
mx=w[0]; my=w[1]; mvx=0; mvy=0; lastMT=performance.now();
try{cv.setPointerCapture(e.pointerId);}catch(_){}
cv.style.cursor='grabbing';
e.preventDefault();
}
});
cv.addEventListener('pointermove',e=>{
const w=toWorld(e);
if(pinned>=0){
const now=performance.now(), dtm=Math.max(0.008,(now-lastMT)/1000);
mvx=0.6*mvx+0.4*(w[0]-mx)/dtm; mvy=0.6*mvy+0.4*(w[1]-my)/dtm;
const mv=Math.hypot(mvx,mvy);
if(mv>12){mvx*=12/mv; mvy*=12/mv;}
lastMT=now; mx=w[0]; my=w[1];
}else{
let near=false;
for(let i=1;i<N;i++) if(Math.hypot(px[i]-w[0],py[i]-w[1])<0.3){near=true;break;}
cv.style.cursor=near?'grab':'default';
}
});
function endPin(){
if(pinned>=0){im[pinned]=1/baseM[pinned]; pinned=-1; cv.style.cursor='default';}
}
cv.addEventListener('pointerup',endPin);
cv.addEventListener('pointercancel',endPin);
cv.addEventListener('lostpointercapture',endPin);
/* ================= main loop ================= */
let tailEMA=0, acc=0, lastT=performance.now();
function frame(now){
requestAnimationFrame(frame);
let dt=(now-lastT)/1000; lastT=now;
if(dt>0.05)dt=0.05; if(dt<0)dt=0;
psiNow+=(psiTarget-psiNow)*(1-Math.exp(-dt/0.3)); // line lag
wheelAng+=(psiTarget/60*22-wheelAng)*(1-Math.exp(-dt/0.3));// valve wheel spins
acc+=dt;
let n=0;
while(acc>=SUB_DT&&n<10){substep(SUB_DT); acc-=SUB_DT; n++;}
if(n===10) acc=0;
if(pinned>=0&&now-lastMT>50){mvx=0;mvy=0;}
spawnWater(dt); updateWater(dt); updateSplashes(dt);
for(let i=stains.length-1;i>=0;i--) if((stains[i].age+=dt)>26) stains.splice(i,1);
let sp=0;
for(let i=N-6;i<N;i++) sp+=Math.hypot(px[i]-ox[i],py[i]-oy[i]);
tailEMA+=(sp/(6*SUB_DT)-tailEMA)*Math.min(1,dt*8);
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.drawImage(bg,0,0,W,H);
drawStains(); drawWheel(); drawHose(); drawNozzle(); drawWater(); drawSplashes(); drawPin();
updateHUD(); updateSound();
}
/* ================= boot ================= */
function resize(){
dpr=Math.min(2,window.devicePixelRatio||1);
W=window.innerWidth; H=window.innerHeight;
cv.width=Math.round(W*dpr); cv.height=Math.round(H*dpr);
S=W/WORLD_W; groundY=Math.round(H*GROUND_FRAC);
buildBackground();
}
window.addEventListener('resize',resize);
slider.addEventListener('input',()=>{psiTarget=parseFloat(slider.value);});
(function(){
/* #psi=40 (any number, clamped to the 0-60 psi gauge) */
const mh=location.hash.match(/psi\s*=\s*([-+]?\d*\.?\d+)/i);
if(mh) psiTarget=psiNow=Math.min(60,Math.max(0,parseFloat(mh[1])));
slider.value=psiTarget;
resize(); initHose(); buildGauge();
for(let k=0;k<160;k++) substep(SUB_DT); // settle under load before first paint
for(let k=0;k<40;k++){spawnWater(1/60); updateWater(1/60);}
requestAnimationFrame(t=>{lastT=t; requestAnimationFrame(frame);});
})();
</script>
</body>
</html>
Where it's honest: the response is graded exactly like the real thing — at 0–8 psi the nozzle can't even break its own friction and sits dead; around 10–14 it creeps and twitches, straightens, and stops; somewhere in the high teens it starts snake-walking; past ~20 the rotating thrust vector keeps feeding momentum and it whips continuously, flicking the nozzle airborne so the jet arcs and the tail cracks like a whip; at full pressure the whole hose drags and hauls itself in loops around the spigot. Lower the slider and it settles again within a second or two. Grabbing it mid-whip pins that point and it thrashes around your hand; release with a flick and it keeps the throw velocity. All of that is emergent from thrust + Coulomb friction + constraints — there is no injected noise, no scripted oscillation, no keyframing anywhere in the hose motion; the only randomness is cosmetic (droplet spread, splash droplets, grass texture).
Where it lies to you, so you hear it from me first: