Asteroids JavaScript: Inspect Both Sides of the Seam

HTML for this example

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

<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Asteroids JavaScript: Inspect Both Sides of the Seam</title><style>:root{color-scheme:light;--ink:#203947;--paper:#f5f4ee;--accent:#265f70;--tint:#e3edf0}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:17px/1.6 system-ui,sans-serif}main{max-width:1060px;margin:auto;padding:clamp(20px,4vw,42px)}header{border-bottom:2px solid var(--ink);margin-bottom:24px;padding-bottom:16px}h1{font:700 clamp(28px,4vw,42px)/1.17 Georgia,serif;margin:10px 0 16px}h2{font:700 24px/1.25 Georgia,serif;margin:14px 0}h3{font-size:inherit}p{margin:12px 0 20px}.eyebrow{font-size:16px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}mark{background:#dceda6;color:#183526;padding:0 3px}nav{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0 24px}button{font:600 16px/1.35 system-ui;border:1.5px solid var(--accent);background:#fff;color:var(--ink);border-radius:8px;padding:12px 16px;min-height:46px;cursor:pointer;max-width:100%;overflow-wrap:anywhere}button:hover{background:var(--tint)}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,textarea:focus-visible,summary:focus-visible{outline:3px solid #a94c25;outline-offset:3px}input,textarea{font:inherit;display:block;color:var(--ink);background:#fff;border:1px solid #7c969d;border-radius:7px;max-width:100%;width:100%;padding:12px;margin:6px 0 16px}textarea{min-height:100px}label{font-weight:600;display:block;font-size:16px}section{min-width:0;padding:16px 0;border-bottom:1px solid #c8d4d4}.twocol{display:grid;grid-template-columns:1fr 1fr;gap:28px}.prompt{border-left:5px solid var(--accent);padding:16px 20px;background:var(--tint);font-size:19px}.result{padding:18px 22px;background:var(--ink);color:#fff;border-radius:10px;font-weight:600;white-space:pre-line}.result:empty{display:none}.ledger{margin:20px 0;overflow-wrap:anywhere}table{width:100%;table-layout:fixed;border-collapse:collapse;font-size:16px}th,td{text-align:left;vertical-align:top;padding:12px 8px;border-bottom:1px solid #ccd6d7;overflow-wrap:anywhere}th{background:var(--tint)}pre{font:16px/1.6 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere;background:#e8edeb;padding:18px;max-width:100%;border-radius:8px}svg{display:block;max-width:100%;width:100%;height:auto;max-height:440px}.board{display:grid;gap:5px;width:100%;max-width:520px;margin:24px auto;padding:10px;background:#c4d5d9;border-radius:14px}.cell{display:flex;align-items:center;justify-content:center;aspect-ratio:1;min-width:0;min-height:0;padding:0;font:700 clamp(16px,3vw,24px)/1 system-ui;background:#fdfcf7;border:0;border-radius:6px}.four{grid-template-columns:repeat(4,1fr)}.five{grid-template-columns:repeat(5,1fr)}.seven,.connect{grid-template-columns:repeat(7,1fr)}.eight{grid-template-columns:repeat(8,1fr);gap:2px;padding:4px}.eight .cell{font-size:16px}.light{background:#f4ead5}.dark{background:#5c7468;color:#fff}.chosen{outline:3px solid #a54823;outline-offset:-3px}.p1,.alive{background:#8b3e29;color:white}.p2{background:#205e75;color:white}.wall{background:#35525d;color:white}.v2,.v4{background:#e7d5ad}.v8,.v16,.v32{background:#d1994b}.v64,.v128,.v256,.v512,.v1024,.v2048{background:#285d68;color:white}.tokens,.hands{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.hands button{margin:5px 4px 5px 0}.notice{padding:24px;background:#fff;border:1px solid #adbec2}.notice h3{font-size:1.2em}.treemap{display:flex;min-height:220px;background:#d6e0e1}.tile{flex-basis:0;min-width:0;background:#2b6274;color:white;border:2px solid var(--paper);padding:12px;overflow-wrap:anywhere}.tile:nth-child(even){background:#774a32}.paths{padding:20px;background:var(--tint);overflow-wrap:anywhere}details{margin:22px 0}summary{cursor:pointer;font-weight:700}footer{border-top:1px solid #b6c8c9;margin-top:28px;padding-top:18px;font-size:16px}.classroom{--paper:#faf5e9;--ink:#263d2d;--accent:#426b3c;--tint:#e7ecd8}.game{--paper:#eef3f5;--ink:#173b4c;--accent:#386f85;--tint:#dcebf0}.code{--paper:#f5f3ee;--ink:#34374c;--accent:#575b86;--tint:#e5e4f0}@media(max-width:650px){.twocol,.hands{grid-template-columns:1fr}.tokens{grid-template-columns:1fr}main{padding:20px}.board{gap:3px;padding:6px}.eight{gap:1px;padding:2px}.connect .cell,.seven .cell{font-size:16px}th,td{padding:10px 5px}button{padding:12px}.prompt{padding:14px}.result{padding:16px}h1{font-size:30px}}</style></head>
<body class="game"><main><header><div class="eyebrow">Original interactive state lab</div><h1>Asteroids JavaScript: Inspect Both Sides of the Seam</h1><p>A ship at x5 and a rock at x315 look close across a 320-unit screen seam. Ordinary subtraction says they are 310 units apart. <mark>The wrapped separation is ten</mark>, and the collision model must use it.</p></header>
<nav><button type="button" id="start">Start</button><button type="button" id="left">Turn left</button><button type="button" id="right">Turn right</button><button type="button" id="thrust">Thrust pulse</button><button type="button" id="fire">Fire</button><button type="button" id="pause">Pause</button><button type="button" id="step">Step 1/60 s</button><button type="button" id="contactExample">Contact order example</button><button type="button" id="reset">Reset</button></nav><svg id="field" viewBox="0 0 320 240" role="img" aria-label="Original two-rock flight field. State summary follows."></svg><p id="result" class="result" role="status"></p><div id="ledger" class="ledger"></div><p id="contactTrace"></p><section><h2>Seam inspector</h2><label for="shipX">Ship x (0–319)</label><input id="shipX" value="5"><label for="rockX">Radius12 rock x (0–319)</label><input id="rockX" value="315"><button type="button" id="inspect">Inspect same-height distance</button><p id="distance" role="status"></p></section>
<footer>Reset restores the stationary ship, both original rocks, and an empty bullet list.</footer></main><script>
(()=>{'use strict';const q=id=>document.querySelector('#'+id);const set=(id,value)=>{q(id).textContent=String(value);};const on=(id,callback)=>q(id).addEventListener('click',callback);const escapeHtml=value=>String(value).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));const table=(id,heads,rows)=>{q(id).innerHTML='<table><thead><tr>'+heads.map(h=>'<th scope="col">'+escapeHtml(h)+'</th>').join('')+'</tr></thead><tbody>'+rows.map(row=>'<tr>'+row.map(value=>'<td>'+escapeHtml(value)+'</td>').join('')+'</tr>').join('')+'</tbody></table>';};const field=(id,label,value='')=>'<label for="'+id+'">'+escapeHtml(label)+'</label><input id="'+id+'" value="'+escapeHtml(value)+'">';const btn=(id,label)=>'<button type="button" id="'+id+'">'+escapeHtml(label)+'</button>';const fmt=value=>Number(value).toFixed(6);
(function(){let ship,rocks,bullets,phase,heading,points,clock,cooldown,nextBulletId;const wrap=(v,n)=>(v%n+n)%n;const dist=(a,b)=>{let x=Math.abs(a.x-b.x),y=Math.abs(a.y-b.y);return Math.hypot(Math.min(x,320-x),Math.min(y,240-y));};function render(){q('field').innerHTML='<rect width="320" height="240" fill="#102637"/>'+rocks.map(r=>`<circle cx="${r.x}" cy="${r.y}" r="${r.r}" fill="none" stroke="#f0c887" stroke-width="2"/>`).join('')+bullets.map(b=>`<circle cx="${b.x}" cy="${b.y}" r="2" fill="white"/>`).join('')+`<path d="M12,0 L-8,-7 L-8,7 Z" transform="translate(${ship.x} ${ship.y}) rotate(${heading*180/Math.PI})" fill="#d4f38a"/>`;set('result',`${phase}; score ${points}; rocks ${rocks.length}; bullets ${bullets.length}.`);set('ledger',`Ship (${ship.x.toFixed(2)},${ship.y.toFixed(2)}), velocity (${ship.vx.toFixed(2)},${ship.vy.toFixed(2)}), heading ${heading.toFixed(3)}. Simulation ${clock.toFixed(3)}s.`);}function reset(){ship={x:160,y:120,vx:0,vy:0};rocks=[{id:'R1',x:40,y:40,r:16,vx:20,vy:0},{id:'R2',x:250,y:180,r:12,vx:-15,vy:0}];bullets=[];phase='Ready';heading=0;points=0;clock=0;cooldown=0;nextBulletId=1;set('contactTrace','No bullet contacts yet.');render();}function crossing(b,r,dt){const vx=b.vx-r.vx,vy=b.vy-r.vy;let contact=Infinity;for(const ox of[-320,0,320])for(const oy of[-240,0,240]){const x=b.x-r.x+ox,y=b.y-r.y+oy,a=vx*vx+vy*vy,c=x*x+y*y-r.r*r.r;if(c<0){contact=0;continue;}if(a<1e-12)continue;const bb=2*(x*vx+y*vy),disc=bb*bb-4*a*c;if(disc<=0)continue;const t=(-bb-Math.sqrt(disc))/(2*a);if(t>=0&&t<Math.min(dt,b.life))contact=Math.min(contact,t);}return contact;}function tick(dt){if(!['Running','Paused'].includes(phase))return;clock+=dt;cooldown=Math.max(0,cooldown-dt);ship.x=wrap(ship.x+ship.vx*dt,320);ship.y=wrap(ship.y+ship.vy*dt,240);if(rocks.some(r=>dist(ship,{x:wrap(r.x+r.vx*dt,320),y:wrap(r.y+r.vy*dt,240)})<7+r.r)){phase='Lost';render();return;}const contacts=[];for(const b of bullets)for(const r of rocks){const t=crossing(b,r,dt);if(Number.isFinite(t))contacts.push({t,b,r});}contacts.sort((a,b)=>a.t-b.t||a.b.id-b.b.id||a.r.id.localeCompare(b.r.id));const removed=new Set(),consumed=new Set(),resolved=[];for(const contact of contacts){if(consumed.has(contact.b.id)||removed.has(contact.r.id))continue;consumed.add(contact.b.id);removed.add(contact.r.id);points+=100;resolved.push('B'+contact.b.id+' → '+contact.r.id+' at '+contact.t.toFixed(6)+' s');}const alive=[];for(const b of bullets)if(!consumed.has(b.id)){b.x=wrap(b.x+b.vx*dt,320);b.y=wrap(b.y+b.vy*dt,240);b.life-=dt;if(b.life>0)alive.push(b);}bullets=alive;set('contactTrace',resolved.length?'Contacts within this update: '+resolved.join('; ')+'.':'No bullet contacts during this update.');rocks=rocks.filter(r=>!removed.has(r.id));for(const r of rocks){r.x=wrap(r.x+r.vx*dt,320);r.y=wrap(r.y+r.vy*dt,240);}if(!rocks.length)phase='Won';render();}on('start',()=>{if(phase==='Ready'||phase==='Paused'){phase='Running';render();}});on('pause',()=>{if(phase==='Running'){phase='Paused';render();}});on('left',()=>{if(['Running','Paused'].includes(phase)){heading-=Math.PI/12;render();}});on('right',()=>{if(['Running','Paused'].includes(phase)){heading+=Math.PI/12;render();}});on('thrust',()=>{if(!['Running','Paused'].includes(phase))return;ship.vx+=Math.cos(heading)*12;ship.vy+=Math.sin(heading)*12;const speed=Math.hypot(ship.vx,ship.vy);if(speed>180){ship.vx*=180/speed;ship.vy*=180/speed;}render();});on('fire',()=>{if(!['Running','Paused'].includes(phase)||cooldown>0)return;bullets.push({id:nextBulletId++,x:ship.x,y:ship.y,vx:220*Math.cos(heading),vy:220*Math.sin(heading),life:1});cooldown=.25;render();});on('step',()=>{if(phase==='Paused')tick(1/60);});on('contactExample',()=>{ship={x:160,y:200,vx:0,vy:0};rocks=[{id:'R1',x:100,y:80,r:10,vx:0,vy:0},{id:'R2',x:150,y:80,r:10,vx:0,vy:0}];bullets=[{id:1,x:50,y:80,vx:6000,vy:0,life:1},{id:2,x:85,y:80,vx:2000,vy:0,life:1}];phase='Paused';heading=0;points=clock=cooldown=0;nextBulletId=3;set('contactTrace','Contact-order example: stationary radius-10 rocks at x 100 and 150, y 80. B1 starts x 50 at 6000 units/s; B2 starts x 85 at 2000 units/s. Press Step.');render();});on('reset',reset);on('inspect',()=>{const a=Number(q('shipX').value),b=Number(q('rockX').value);if(!q('shipX').value.trim()||!q('rockX').value.trim()||![a,b].every(n=>Number.isFinite(n)&&n>=0&&n<320))return set('distance','Enter x coordinates from0 up to but not including320.');const d=Math.min(Math.abs(a-b),320-Math.abs(a-b));set('distance',`Wrapped distance ${d}; radii sum19; ${d<19?'strict overlap':'no strict overlap'}.`);});setInterval(()=>{if(phase==='Running')tick(1/60);},1000/60);document.addEventListener('visibilitychange',()=>{if(document.hidden&&phase==='Running'){phase='Paused';render();}});reset();})();})();
</script></body></html>