One clock for the scrolling world

HTML for this example

Select the code and use your copy shortcut. Changes in this preview are temporary.

<!doctype html>
<html lang="en-US"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>One clock for the scrolling world</title><style>
:root{color-scheme:light;--accent:#4b586c;--wash:#e9eef5;--ink:#202d3b;--paper:#fffdf9}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:#eeeae2;font:17px/1.65 system-ui,-apple-system,"Segoe UI",sans-serif}main{max-width:980px;margin:auto;padding:28px 24px 40px}header{border-top:6px solid var(--accent);padding:20px 0 24px}.kicker{color:var(--accent);font-weight:750;letter-spacing:.06em;margin:0 0 8px}h1{font:700 clamp(30px,5vw,46px)/1.15 Georgia,serif;margin:0 0 14px;max-width:780px}h2{font-size:23px;line-height:1.3;margin:0 0 14px}h3{font-size:19px;margin:12px 0 8px}p{margin:0 0 16px}button,input,textarea,select{font:inherit}button{background:white;border:1px solid #87949b;color:var(--ink);border-radius:8px;padding:11px 16px;min-height:48px;cursor:pointer;font-weight:650}button:hover{background:var(--wash)}button[aria-pressed=true],button.primary{background:var(--accent);color:white;border-color:var(--accent)}:focus-visible{outline:3px solid #b45a1e;outline-offset:3px}label{display:block;font-weight:650;margin:14px 0 6px}input:not([type=checkbox]):not([type=radio]),textarea,select{display:block;width:100%;min-width:0;max-width:100%;border:1px solid #80909b;border-radius:7px;padding:11px;background:white;color:var(--ink)}input[type=checkbox],input[type=radio]{width:21px;height:21px;accent-color:var(--accent)}textarea{resize:vertical}.panel{background:var(--paper);border:1px solid #ccd2d1;border-radius:12px;padding:24px;margin-bottom:20px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.two>.panel{margin:0}.two{margin-bottom:20px}.actions{display:flex;flex-wrap:wrap;gap:10px;margin:18px 0}.result{padding:18px;background:var(--wash);border-left:5px solid var(--accent);margin-top:18px;white-space:pre-line;overflow-wrap:anywhere}.note{color:#465565;font-size:16px}.strip{padding:18px;background:var(--wash);border-radius:8px;margin-bottom:18px}table{border-collapse:collapse;width:100%;font-size:16px;table-layout:fixed}th,td{text-align:left;padding:10px 8px;border-bottom:1px solid #cbd0d1;overflow-wrap:anywhere;vertical-align:top}th{background:var(--wash)}ol,ul{padding-left:24px}li+li{margin-top:8px}pre{font-size:16px;white-space:pre-wrap;overflow-wrap:anywhere}code{font-size:16px}.grid{display:grid;gap:5px;max-width:440px}.grid button{padding:5px;min-width:0}.grid input{padding:5px!important;text-align:center;font-weight:750}blockquote{font:500 26px/1.5 Georgia,serif;margin:16px 0}footer{font-size:16px;color:#465565;margin-top:22px}[hidden]{display:none!important}svg{display:block;max-width:100%;height:auto}mark{background:#e0efad;color:var(--ink)}@media(max-width:560px){main{padding:18px 12px 28px}.panel{padding:20px 16px}.two{grid-template-columns:minmax(0,1fr)}h1{font-size:32px}.actions button{flex:1 1 135px}th,td{padding:8px 5px}.grid button{min-height:40px}blockquote{font-size:24px}}
.arena{position:relative;width:100%;max-width:320px;aspect-ratio:4/3;background:#202d3b;overflow:hidden;border:2px solid #8997a8;margin:18px auto}.entity{position:absolute;border:2px solid white;border-radius:4px}.roadline{position:absolute;top:0;height:100%;border-left:2px dashed #a1acb7}</style></head><body><main><header><p class="kicker">ENDLESS RECTANGLE RUNNER · ACTIVE-TIME MODEL</p><h1>One clock for the scrolling world</h1><p>A fixed hero, a scrolling obstacle stream, and one active simulation clock. Logical pixels only; no real running measurement.</p></header><section id="region" class="panel" tabindex="0" aria-label="Runner game region"><div class="arena" id="field"><div style="position:absolute;top:91.6667%;width:100%;border-top:2px solid #d8dfe4"></div><div id="entities"></div></div><div class="actions"><button type="button" id="start">Start</button><button type="button" id="jump">Jump</button><button type="button" id="pause">Pause</button><button type="button" id="resume">Resume</button><button type="button" id="reset">Reset</button></div><div id="state" class="result" role="status" aria-live="polite">Ready</div><div id="world" class="result" role="status" aria-live="polite"></div><div id="obstacles" class="result" role="status" aria-live="polite"></div><div class="actions"><button type="button" id="noJump">Inspect no-jump loss</button><button type="button" id="intervalCheck">Inspect jump at 2.02s</button><p id="intervalResult" role="status">Interval comparison not run.</p><p id="inputState" role="status">Space uses a fresh grounded request.</p></div><p>Space/Arrow Up within this region: fresh grounded jump. Held repeats and airborne jump requests are ignored.</p></section><footer>Distance measures world scrolling. Pause freezes the simulation, and the obstacle stream continues until loss.</footer><noscript>JavaScript is required for the local controls. The accompanying article contains worked examples.</noscript></main><script>
(()=>{'use strict';const $=id=>document.getElementById(id);
let phase='Ready',tick=0,y=200,velocity=0,grounded=true,passed=0,generation=0,index=0,raf=null,last=0,acc=0;let obs=[];const held=new Set();function spawn(){obs.push({id:'G'+generation+'-O'+(++index),at:tick,x:320});}// Intersect the open horizontal-overlap interval with the continuous vertical trajectory.
function intervalCollision(startY,startVelocity,startX,dt){
  const left=Math.max(0,(startX-70)/100),right=Math.min(dt,(startX-26)/100);
  if(left>=right)return false;
  const topAt=t=>Math.min(200,startY+startVelocity*t+300*t*t);
  const ends=[topAt(left),topAt(right)],turn=-startVelocity/600;
  if(turn>left&&turn<right)ends.push(topAt(turn));
  return Math.max(...ends)>176&&Math.min(...ends)<220;
}
function step(){if(phase!=='Running')return;const previousY=y,previousVelocity=velocity;tick++;if(tick%180===0)spawn();y+=velocity*.01+.5*600*.0001;velocity+=6;if(y>=200&&velocity>0){y=200;velocity=0;grounded=true;}for(const o of obs)o.x=320-(tick-o.at);if(obs.some(o=>(o.at<tick&&intervalCollision(previousY,previousVelocity,320-(tick-1-o.at),.01))||(o.x<70&&o.x+24>50&&y<220&&y+20>196))){phase='Lost';return;}const survivors=[];for(const o of obs)if(o.x+24<50)passed++;else survivors.push(o);obs=survivors;}
function render(){$('state').textContent=phase+' / Distance '+tick+' logical pixels / Passed '+passed;$('world').textContent='Time '+(tick/100).toFixed(2)+'s / Hero x50; y'+y.toFixed(3)+' / '+(grounded?'grounded':'airborne')+' / Next spawn '+((Math.floor(tick/180)+1)*1.8).toFixed(1)+'s';$('obstacles').textContent=obs.map(o=>o.id+' x'+o.x.toFixed(0)).join(' / ')||'No active obstacles.';$('entities').replaceChildren();function rect(x,top,w,h,color){const d=document.createElement('div');d.className='entity';d.style.cssText='left:'+x/320*100+'%;top:'+top/240*100+'%;width:'+w/320*100+'%;height:'+h/240*100+'%;background:'+color;$('entities').append(d);}rect(50,y,20,20,'#b7d76e');for(const o of obs)rect(o.x,196,24,24,'#c4875d');}function reset(){generation++;cancelAnimationFrame(raf);phase='Ready';tick=0;y=200;velocity=0;grounded=true;passed=0;index=0;obs=[];held.clear();acc=0;render();}function animate(){const gen=generation;last=performance.now();function frame(now){if(gen!==generation||phase!=='Running')return;acc+=Math.min(.1,Math.max(0,(now-last)/1000));last=now;const count=Math.min(10,Math.floor((acc+1e-9)/.01));acc-=count*.01;for(let i=0;i<count&&phase==='Running';i++)step();render();if(phase==='Running')raf=requestAnimationFrame(frame);}raf=requestAnimationFrame(frame);}function jump(){if(phase!=='Running'||!grounded)return;velocity=-300;grounded=false;render();}function pause(){if(phase!=='Running')return;phase='Paused';cancelAnimationFrame(raf);held.clear();render();}$('start').addEventListener('click',()=>{if(phase!=='Ready')return;phase='Running';spawn();animate();render();});let suppressSpaceClick=false;$('jump').addEventListener('pointerdown',()=>{suppressSpaceClick=false;});$('jump').addEventListener('click',e=>{if(e.detail===0&&suppressSpaceClick){suppressSpaceClick=false;return;}suppressSpaceClick=false;jump();});$('pause').addEventListener('click',pause);$('resume').addEventListener('click',()=>{if(phase!=='Paused')return;phase='Running';acc=0;held.clear();animate();render();});$('reset').addEventListener('click',reset);$('region').addEventListener('keydown',e=>{const isSpace=e.key===' '||e.key==='Space'||e.code==='Space',onJump=e.target===$('jump');if(onJump&&e.key==='Enter'){suppressSpaceClick=false;return;}if(e.repeat&&e.target.tagName==='BUTTON'){e.preventDefault();return;}if(!isSpace&&e.key!=='ArrowUp')return;if(e.target.tagName==='BUTTON'&&isSpace&&!onJump)return;e.preventDefault();if(onJump&&isSpace)suppressSpaceClick=true;if(e.repeat||held.has(e.key))return;held.add(e.key);const accepted=phase==='Running'&&grounded;jump();if(isSpace)$('inputState').textContent=accepted?'Space jump accepted on keydown.':'Space ignored; no jump queued.';});$('region').addEventListener('keyup',e=>{if((e.key===' '||e.key==='Space'||e.code==='Space')&&e.target===$('jump'))e.preventDefault();held.delete(e.key);});$('region').addEventListener('focusout',e=>{held.clear();if(!e.relatedTarget||!$('region').contains(e.relatedTarget))pause();});document.addEventListener('visibilitychange',()=>{if(document.hidden)pause();});$('noJump').addEventListener('click',()=>{reset();phase='Running';spawn();for(let i=0;i<300&&phase==='Running';i++)step();render();});$('intervalCheck').addEventListener('click',()=>{const saved={phase,tick,y,velocity,grounded,passed,index,obs};phase='Running';tick=0;y=200;velocity=0;grounded=true;passed=0;index=0;obs=[];spawn();for(let n=0;n<500&&phase==='Running';n++){if(tick===202)jump();step();}const result='Jump at 2.02s: '+phase+' / Time '+(tick/100).toFixed(2)+'s / Distance '+tick+' / Passed '+passed+'; descending interval overlap.';({phase,tick,y,velocity,grounded,passed,index,obs}=saved);render();$('intervalResult').textContent=result;});reset();
})();
</script></body></html>