Platform lab · One goal
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>Platform lab · One goal</title><style>*{box-sizing:border-box}body{margin:0;background:#f3f1eb;color:#202e35;font:16px/1.5 system-ui,sans-serif}main{max-width:1160px;margin:auto;padding:32px 28px 70px}h1{font-size:clamp(26px,4vw,42px);line-height:1.12;margin:8px 0 12px;letter-spacing:-.04em}h2{font-size:22px;margin:0 0 16px}h3{font-size:17px;margin:0 0 8px}p{margin:8px 0 16px}.eyebrow{text-transform:uppercase;letter-spacing:.15em;font-size:12px;font-weight:750;color:#45665e}.lede{color:#55636a;max-width:780px}.panel{background:#fff;border:1px solid #d9dfd9;border-radius:16px;padding:24px;margin:20px 0}.hero{background:#193f38;color:white}.hero .muted{color:#bfd6cb}.split{display:grid;grid-template-columns:1.1fr 1fr;gap:24px}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.row{display:flex;flex-wrap:wrap;gap:12px;align-items:center}.big{font-size:clamp(32px,5vw,58px);line-height:1.15;font-weight:750;letter-spacing:-.04em}.muted,small{color:#61716e}.badge{display:inline-block;padding:4px 9px;border-radius:20px;background:#e5efe8;color:#284e40;font-size:13px;font-weight:650}.warn{background:#fff2d2;color:#795213}.rule{border-top:1px solid #dce2dd;padding-top:16px;margin-top:16px}label{display:block;font-size:14px;font-weight:650;margin:12px 0 5px}input,select,textarea,button{font:inherit}input,select,textarea{width:100%;border:1px solid #aebbb5;border-radius:7px;background:white;color:#20332c;padding:10px;min-height:44px}textarea{min-height:96px;resize:vertical}button{min-height:44px;border:1px solid #80968c;border-radius:7px;padding:9px 14px;background:white;color:#1c4637;cursor:pointer}button.primary{background:#245d47;color:white;border-color:#245d47}button:disabled{opacity:.5;cursor:default}button[aria-pressed=true]{background:#d8eb90;color:#1c3d29;border-color:#536a2d}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible{outline:3px solid #d5782a;outline-offset:3px}.error{color:#913920;min-height:24px}pre,.record{white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.6 ui-monospace,monospace;background:#eef2ec;border-radius:8px;padding:16px}table{border-collapse:collapse;width:100%;font-size:14px}th,td{border-bottom:1px solid #dce2dd;text-align:left;padding:12px 8px;vertical-align:top}th{font-size:12px;text-transform:uppercase;letter-spacing:.06em}.scroll{overflow:auto}details{margin:24px 0}summary{cursor:pointer;min-height:44px;font-weight:650}svg{max-width:100%;height:auto}.small{font-size:13px}.stack>*+*{margin-top:12px}@media(max-width:720px){main{padding:22px 16px 48px}.split{grid-template-columns:1fr}.grid{grid-template-columns:repeat(2,minmax(0,1fr))}.panel{padding:18px;margin:16px 0}h1{font-size:32px}.desktop-table{min-width:680px}}@media(max-width:400px){.grid{grid-template-columns:1fr}main{padding:18px 14px}.panel{padding:16px}.row>button{flex:1}h1{font-size:29px}}</style></head><body><main><div class="eyebrow">JavaScript movement lab</div><h1>A small world, precise collisions</h1><p class="lede">Reach the outlined goal; use the platform to explore landing and head contact.</p><section class="panel"><div class="row"><span class="badge" id="phase">Awaiting Start</span><span class="small" id="position">x 20 · y 200 · grounded</span></div><svg id="world" viewBox="0 0 320 240" tabindex="0" role="group" aria-label="Platform game. Focus here for Left and Right arrows and Space to jump." style="display:block;width:100%;max-height:460px;margin:18px auto;background:#e6eee3;border-radius:12px"><path d="M0 40H320 M0 80H320 M0 120H320 M0 160H320 M0 200H320" stroke="#d4dfd1"/><rect x="0" y="220" width="320" height="20" fill="#3b6551"/><rect x="120" y="160" width="80" height="10" fill="#598271"/><rect x="260" y="200" width="20" height="20" fill="#e9d472" stroke="#6a5e25" stroke-width="2"/><text x="255" y="191" font-size="10" fill="#455b45">GOAL</text><rect id="player" x="20" y="200" width="20" height="20" rx="2" fill="#c66638"/></svg><div class="row"><button id="start" class="primary">Start</button><button id="pause" disabled>Pause</button><button id="resume" disabled>Resume</button><button id="reset">Reset</button></div><p id="message" role="status">Start, then focus the world for arrow keys and Space, or use the controls below.</p><div class="row"><button id="left" disabled aria-pressed="false">Start left</button><button id="right" disabled aria-pressed="false">Start right</button><button id="stop" disabled>Stop movement</button><button id="jump" disabled>Jump</button></div><p class="small">Native direction buttons toggle movement on/off. Arrow keys move while held. Pause clears all input.</p></section><details><summary>Movement contract</summary><p>World 320 × 240; player 20 × 20. Speed 120 px/s, gravity 600 px/s², jump −300 px/s. Fixed steps of 1/120 second resolve horizontal motion first, then vertical face crossings. The floor is y=220 and the platform spans x=120–200, y=160–170. Head contact does not grant another jump. Both directions together cancel.</p><p>A fresh jump requires grounded state. Blur or a hidden page pauses the game; Resume starts a fresh clock. Accepted frame time is capped at 50 ms. Strict overlap wins: x=240 just touches the goal; x=241 overlaps. The goal is reachable on the floor. These illustrative mechanics are not a physical simulation or a saved game.</p></details></main><script>const $=id=>document.getElementById(id),put=(id,v)=>$(id).textContent=String(v),esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const solids=[{x:0,y:220,w:320,h:20},{x:120,y:160,w:80,h:10}],initial=()=>({x:20,y:200,vy:0,grounded:true,phase:'awaiting'});let state=initial(),left=false,right=false,jumpHeld=false,raf=null,session=0,last=null,acc=0;const overlap=(a,b)=>a.x<b.x+b.w&&a.x+20>b.x&&a.y<b.y+b.h&&a.y+20>b.y;
function step(p,dt,l,r){if(p.phase!=='playing')return {...p};const n={...p},oldX=p.x,dx=((r?1:0)-(l?1:0))*120*dt;let nx=Math.max(0,Math.min(300,oldX+dx));for(const b of solids){if(!(p.y<b.y+b.h&&p.y+20>b.y))continue;if(dx>0&&oldX+20<=b.x&&nx+20>b.x)nx=Math.min(nx,b.x-20);if(dx<0&&oldX>=b.x+b.w&&nx<b.x+b.w)nx=Math.max(nx,b.x+b.w)}n.x=nx;n.grounded=solids.some(b=>n.x+20>b.x&&n.x<b.x+b.w&&Math.abs(n.y+20-b.y)<1e-8);n.vy+=600*dt;let ny=n.y+n.vy*dt,hit=false;if(n.vy>=0){let face=Infinity;for(const b of solids)if(n.x+20>b.x&&n.x<b.x+b.w&&n.y+20<=b.y&&ny+20>=b.y)face=Math.min(face,b.y);if(face!==Infinity){ny=face-20;n.vy=0;hit=true}}else{let face=-Infinity;for(const b of solids)if(n.x+20>b.x&&n.x<b.x+b.w&&n.y>=b.y+b.h&&ny<=b.y+b.h)face=Math.max(face,b.y+b.h);if(ny<0)face=Math.max(face,0);if(face!==-Infinity){ny=face;n.vy=0}}n.y=ny;n.grounded=hit;if(overlap(n,{x:260,y:200,w:20,h:20}))n.phase='won';return n}
function jump(p){if(p.phase!=='playing'||!p.grounded)return false;p.vy=-300;p.grounded=false;return true}function clearInput(){left=right=jumpHeld=false}function cancel(){session++;if(raf!==null)cancelAnimationFrame(raf);raf=null;last=null;acc=0;clearInput()}
function render(){put('phase',({awaiting:'Awaiting Start',playing:'Playing',paused:'Paused',won:'Goal reached'})[state.phase]);put('position','x '+state.x.toFixed(2)+' · y '+state.y.toFixed(2)+' · '+(state.grounded?'grounded':'airborne'));$('player').setAttribute('x',state.x);$('player').setAttribute('y',state.y);$('start').disabled=state.phase!=='awaiting';$('pause').disabled=state.phase!=='playing';$('resume').disabled=state.phase!=='paused';for(const id of ['left','right','stop','jump'])$(id).disabled=state.phase!=='playing';$('jump').disabled=state.phase!=='playing'||!state.grounded;for(const id of ['left','right']){$(id).setAttribute('aria-pressed',id==='left'?left:right);put(id,((id==='left'?left:right)?'Stop ':'Start ')+id)}}
function frame(t,token){if(token!==session||state.phase!=='playing')return;raf=null;if(last!==null)acc+=Math.min(.05,Math.max(0,(t-last)/1000));last=t;while(acc+1e-12>=1/120&&state.phase==='playing'){state=step(state,1/120,left,right);acc-=1/120}if(state.phase==='won'){cancel();put('message','Goal reached. Reset to play again.');render();return}render();raf=requestAnimationFrame(t=>frame(t,token))}
function run(){if(!['awaiting','paused'].includes(state.phase))return;cancel();state.phase='playing';const token=session;raf=requestAnimationFrame(t=>frame(t,token));put('message','Playing. Focus the world for arrow keys and Space.');render()}function pause(){if(state.phase!=='playing')return;cancel();state.phase='paused';put('message','Paused. Input cleared; Resume starts a fresh clock.');render()}
$('start').addEventListener('click',()=>{run();$('world').focus()});$('resume').addEventListener('click',run);$('pause').addEventListener('click',pause);$('reset').addEventListener('click',()=>{cancel();state=initial();put('message','Reset to x 20, y 200. Awaiting Start.');render()});for(const id of ['left','right'])$(id).addEventListener('click',()=>{if(state.phase!=='playing')return;if(id==='left')left=!left;else right=!right;render()});$('stop').addEventListener('click',()=>{clearInput();render()});$('jump').addEventListener('click',()=>{if(jump(state))put('message','Jump started.');render()});$('world').addEventListener('keydown',e=>{if(!['ArrowLeft','ArrowRight',' '].includes(e.key))return;e.preventDefault();if(state.phase!=='playing')return;if(e.key==='ArrowLeft')left=true;if(e.key==='ArrowRight')right=true;if(e.key===' '&&!jumpHeld&&!e.repeat){jumpHeld=true;jump(state)}render()});$('world').addEventListener('keyup',e=>{if(e.key==='ArrowLeft')left=false;if(e.key==='ArrowRight')right=false;if(e.key===' ')jumpHeld=false;render()});$('world').addEventListener('blur',()=>{clearInput();render()});if(typeof window!=='undefined')window.addEventListener('blur',pause);if(document.addEventListener)document.addEventListener('visibilitychange',()=>{if(document.hidden)pause()});globalThis.Tool={step,jump,initial,overlap,getState:()=>state,getInput:()=>({left,right,jumpHeld}),getSession:()=>session,frame,pause};render();</script></body></html>