SOURCE / PINNED RELEASE
Made of little things.
Impossible Machine
- Release
- 841127ac75f3…
- Author-recorded commit
- 123625b81870…
- License
- Check the original source
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-47d97da9d80
The hash-verified playable HTML, which may contain bundled or minified code. This view does not execute it.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Impossible Machine</title>
<script type="module" crossorigin>function ce(){const e=new Map;function t(o,i){if(e.has(o))throw new Error(`NAP domain "${o}" is already registered`);e.set(o,i)}function r(o){const i=o.type.indexOf(".");if(i<=0)return!1;const y=o.type.slice(0,i),u=e.get(y);return u?(u(o),!0):!1}function a(){return Array.from(e.keys())}return{registerNap:t,dispatch:r,getRegisteredDomains:a}}var le=ce(),ue=le.registerNap,de="theme";function ee(){var t;const e=window;if(!((t=e.napplet)!=null&&t.theme))throw new Error("window.napplet.theme is unavailable -- runtime did not inject this domain");return e.napplet.theme}function ye(){return ee().get()}function he(e){return ee().onChanged(e)}ue(de,e=>{});function fe(){const e=window;if(!e.napplet)throw new Error("window.napplet is unavailable -- runtime did not inject the napplet namespace");return e.napplet}function h(e){const r=fe()[e];if(!r)throw new Error(`window.napplet.${String(e)} is unavailable -- runtime did not inject this domain`);return r}var te={getItem(e){return h("storage").getItem(e)},setItem(e,t){return h("storage").setItem(e,t)},removeItem(e){return h("storage").removeItem(e)},keys(){return h("storage").keys()},instance:{getItem(e){return h("storage").instance.getItem(e)},setItem(e,t){return h("storage").instance.setItem(e,t)},removeItem(e){return h("storage").instance.removeItem(e)},keys(){return h("storage").instance.keys()}}},me={get(){return h("config").get()},subscribe(e){return h("config").subscribe(e)},openSettings(e){h("config").openSettings(e)},registerSchema(e,t){return h("config").registerSchema(e,t)},onSchemaError(e){return h("config").onSchemaError(e)},get schema(){return h("config").schema}};function pe(e,t){return!!(e!=null&&e[t])}function E(e){return pe(window.napplet,e)}function j(e){const t=Math.cos(e.angle)*70,r=Math.sin(e.angle)*70;return[e.x-t,e.y-r,e.x+t,e.y+r]}function ge(e,t,r){e.vy+=480*r,e.x+=e.vx*r,e.y+=e.vy*r;for(const a of t){let o=a.x,i=a.y,y=34;if(a.kind==="ramp"){const[k,O,_,X]=j(a),Q=Math.max(0,Math.min(1,((e.x-k)*(_-k)+(e.y-O)*(X-O))/19600));o=k+Q*(_-k),i=O+Q*(X-O),y=16}let u=e.x-o,f=e.y-i,b=Math.hypot(u,f);if(b>=y)continue;b<1e-4&&(u=0,f=-1,b=1);const R=u/b,D=f/b;e.x=o+R*y,e.y=i+D*y;const z=e.vx*R+e.vy*D;if(z<0){const k=a.kind==="bumper"?1.65:1.08;e.vx-=k*z*R,e.vy-=k*z*D,a.kind==="bumper"&&(e.vx+=R*55,e.vy+=D*55)}}}function xe(e,t){return Math.abs(e.x-t.x)<29&&e.y>t.y-10&&e.y<t.y+22&&e.vy>=0}const s=e=>document.getElementById(e),p=s("board"),n=p.getContext("2d"),C=[{start:{x:125,y:90},cup:{x:435,y:505},hint:"Catch the fall with a tilted ramp. Build a path to the cup.",pieces:[{x:160,y:230,angle:.28},{x:345,y:380,angle:-.2}]},{start:{x:100,y:80},cup:{x:495,y:510},hint:"A longer journey. Try a chain of ramps, or a well-placed bumper.",pieces:[{x:140,y:210,angle:.4},{x:335,y:350,angle:.2}]},{start:{x:470,y:75},cup:{x:115,y:510},hint:"Send it back the other way. Negative angles roll the marble left.",pieces:[{x:425,y:220,angle:-.3},{x:220,y:365,angle:.2}]},{start:{x:170,y:75},cup:{x:420,y:510},hint:"No rules, no rush. Build something delightfully unnecessary.",pieces:[{x:205,y:220,angle:.3}]}];let g=0,l=[],x=0,B=0,c={x:0,y:0,vx:0,vy:0},d="edit",F=0,W=0,S=[],H=[],$=new Set,ne=!0,M=1,K=0,V=0,P=0,A=0,I=null;const J=[];function w(e){s("status").textContent=e}function v(){const e=s("piece");e.replaceChildren(...l.map((r,a)=>{const o=document.createElement("option");return o.value=String(r.id),o.textContent=`${r.kind==="ramp"?"Ramp":"Bumper"} ${a+1}`,o})),e.value=String(x);const t=l.find(r=>r.id===x);for(const r of["left","right"])s(r).disabled=!t||t.kind!=="ramp"||d==="run";s("remove").disabled=!t||d==="run";for(const r of["ramp","bumper"])s(r).disabled=l.length>=12||d==="run";s("solved").textContent=`${$.size} / 3`,s("phase").textContent=d==="run"?"LET GRAVITY DO ITS THING":d==="won"?"A WORK OF GENIUS":"YOUR WORKBENCH",s("attempt").textContent=`TAKE ${String(Math.max(1,F)).padStart(2,"0")}`,s("drop").disabled=d==="run"}function G(){S.length&&(H=[...S]),S=[],d="edit",W=0,c={...C[g].start,vx:0,vy:0},s("celebration").hidden=!0,w("Ready for another little experiment."),v()}function Y(){l=C[g].pieces.map(e=>({...e,id:++B,kind:"ramp"})),l.push({id:++B,kind:"bumper",x:480,y:330,angle:0}),x=l[0].id,F=0,H=[],S=[],G(),s("hint").textContent=C[g].hint,w("Drag to move · Select a ramp, then ↶ ↷ to tilt")}function N(){d!=="edit"&&G()}async function re(){if(!E("storage")){w("Progress stays in this session. Saving is unavailable here.");return}try{await te.setItem("impossible-machine:solved",JSON.stringify([...$])),w("Progress saved. Your next experiment awaits.")}catch{w("Could not save. You can keep playing in this session.")}}s("save").onclick=()=>void re();s("level").onchange=e=>{g=Number(e.target.value),Y()};s("reset").onclick=Y;s("retry").onclick=G;s("drop").onclick=()=>{G(),d="run",F++,w("Rolling… You can retry at any time."),v()};s("next").onclick=()=>{g=(g+1)%C.length,s("level").value=String(g),Y()};for(const e of["ramp","bumper"])s(e).onclick=()=>{N(),!(l.length>=12)&&(l.push({id:++B,kind:e,x:300+(l.length%3-1)*25,y:280,angle:.25}),x=B,v())};s("piece").onchange=e=>{x=Number(e.target.value),v()};for(const[e,t]of[["left",-.13],["right",.13]])s(e).onclick=()=>{N();const r=l.find(a=>a.id===x);r&&(r.angle=Math.max(-1.4,Math.min(1.4,r.angle+t))),v()};s("remove").onclick=()=>{var e;N(),l=l.filter(t=>t.id!==x),x=((e=l[0])==null?void 0:e.id)??0,v()};for(const e of document.querySelectorAll(".nudges button"))e.onclick=()=>{N();const t=l.find(r=>r.id===x);t&&(t.x=Math.max(40,Math.min(560,t.x+Number(e.dataset.dx??0))),t.y=Math.max(110,Math.min(550,t.y+Number(e.dataset.dy??0))))};function ie(e){const t=p.getBoundingClientRect();return{x:(e.clientX-t.left-K)/M,y:(e.clientY-t.top-V)/M}}p.onpointerdown=e=>{if(d==="run")return;const t=ie(e),r=[...l].reverse().find(a=>{if(a.kind==="bumper")return Math.hypot(t.x-a.x,t.y-a.y)<40;const[o,i,y,u]=j(a),f=Math.max(0,Math.min(1,((t.x-o)*(y-o)+(t.y-i)*(u-i))/19600));return Math.hypot(t.x-o-f*(y-o),t.y-i-f*(u-i))<29});r&&(N(),x=r.id,I={id:r.id,dx:t.x-r.x,dy:t.y-r.y},p.setPointerCapture(e.pointerId),v())};p.onpointermove=e=>{if(!I)return;const t=l.find(a=>a.id===I.id),r=ie(e);t.x=Math.max(35,Math.min(565,r.x-I.dx)),t.y=Math.max(110,Math.min(555,r.y-I.dy))};p.onpointerup=p.onpointercancel=()=>{I=null};function T(e,t,r,a,o,i){n.beginPath(),n.moveTo(e,t),n.lineTo(r,a),n.strokeStyle=o,n.lineWidth=i,n.lineCap="round",n.stroke()}function m(e,t,r,a){n.beginPath(),n.arc(e,t,r,0,Math.PI*2),n.fillStyle=a,n.fill()}function ve(){const e=getComputedStyle(document.documentElement),t=e.getPropertyValue("--fg").trim(),r=e.getPropertyValue("--muted").trim();n.clearRect(0,0,P,A),n.save(),n.translate(K,V),n.scale(M,M);const a=C[g];if(n.globalAlpha=.15,T(35,565,565,565,t,1),n.globalAlpha=1,n.font="11px ui-monospace,monospace",n.fillStyle=r,n.textAlign="center",n.fillText("THE DROP",a.start.x,a.start.y-30),T(a.start.x,a.start.y+22,a.start.x,a.start.y+42,"#b0b7ac",2),ne){for(const[i,y]of[[H,.18],[S,.45]])n.globalAlpha=y,i.forEach((u,f)=>{f%4===0&&m(u.x,u.y,2.5,"#719c89")});n.globalAlpha=1}for(const i of l)if(i.id===x&&d!=="run"&&(n.setLineDash([4,5]),n.strokeStyle="#729889",n.lineWidth=1.5,n.beginPath(),n.arc(i.x,i.y,i.kind==="ramp"?84:37,0,Math.PI*2),n.stroke(),n.setLineDash([])),i.kind==="ramp"){const[y,u,f,b]=j(i);T(y,u+5,f,b+5,"#00000015",15),T(y,u,f,b,"#9b6739",13),T(y,u-2,f,b-2,"#d9ac71",9),m(i.x,i.y,3,"#8d6037"),m(i.x-1,i.y-1,1,"#f7dfbb")}else m(i.x,i.y+4,25,"#00000015"),m(i.x,i.y,25,"#b56151"),m(i.x,i.y-2,21,"#e7987b"),m(i.x,i.y-2,13,"#f0b99c"),m(i.x,i.y-2,6,"#ba6755");const o=a.cup;if(n.fillStyle="#78a294",n.beginPath(),n.moveTo(o.x-38,o.y),n.lineTo(o.x-29,o.y+49),n.quadraticCurveTo(o.x,o.y+60,o.x+29,o.y+49),n.lineTo(o.x+38,o.y),n.closePath(),n.fill(),n.strokeStyle="#426c5f",n.lineWidth=3,n.stroke(),n.fillStyle="#34594f",n.beginPath(),n.ellipse(o.x,o.y,36,8,0,0,Math.PI*2),n.fill(),n.strokeStyle="#b6d3bf",n.stroke(),n.font="18px Georgia",n.fillStyle="#e8eee0",n.fillText("★",o.x,o.y+35),d!=="won"){m(c.x+2,c.y+4,11,"#00000018");const i=n.createRadialGradient(c.x-4,c.y-5,1,c.x,c.y,11);i.addColorStop(0,"#fff1bf"),i.addColorStop(.4,"#e9b951"),i.addColorStop(1,"#b87c25"),m(c.x,c.y,10,i),m(c.x-3,c.y-4,2,"#fff8db")}n.restore()}function oe(){const e=p.getBoundingClientRect();P=e.width,A=e.height;const t=Math.min(devicePixelRatio,2);p.width=P*t,p.height=A*t,n.setTransform(t,0,0,t,0,0),M=Math.min(P/600,A/630),K=(P-600*M)/2,V=(A-600*M)/2+8}const ae=new ResizeObserver(oe);ae.observe(p);let L=0,q=0,U=0;function se(e){const t=L?Math.min((e-L)/1e3,.05):0;if(L=e,d==="run"){for(q+=t;q>=1/240&&d==="run";)ge(c,l,1/240),W+=1/240,q-=1/240,xe(c,C[g].cup)?(d="won",g<3&&$.add(g),s("celebration").hidden=!1,w("In the cup! Try the next puzzle, or refine your machine."),E("storage")&&re(),v()):(c.y>650||c.x<-50||c.x>650||W>12)&&(d="miss",w("Almost! Move a piece, or tap Retry to try again."),v());S.length<1200&&S.push({x:c.x,y:c.y})}else q=0;ve(),U=requestAnimationFrame(se)}function Z(e){const t=document.documentElement.style;t.setProperty("--bg",e.colors.background),t.setProperty("--fg",e.colors.text),t.setProperty("--primary",e.colors.primary)}if(E("theme"))try{ye().then(Z).catch(()=>{}),J.push(he(Z))}catch{}if(E("config"))try{J.push(me.subscribe(e=>{ne=e.showTrail!==!1}))}catch{}if(E("storage"))try{te.getItem("impossible-machine:solved").then(e=>{try{const t=JSON.parse(e??"[]");Array.isArray(t)&&t.filter(r=>typeof r=="number"&&Number.isInteger(r)&&r>=0&&r<3).forEach(r=>$.add(r)),v()}catch{}}).catch(()=>{})}catch{}window.addEventListener("pagehide",()=>{J.forEach(e=>e.close()),ae.disconnect(),cancelAnimationFrame(U)},{once:!0});Y();oe();U=requestAnimationFrame(se);
</script>
<style>:root{font-family:ui-rounded,Avenir Next,system-ui,sans-serif;font-size:13px;color-scheme:light dark;--bg: #f5f1e7;--fg: #343d39;--primary: #387c69;--surface: color-mix(in srgb, var(--bg) 94%, var(--fg));--line: color-mix(in srgb, var(--fg) 15%, var(--bg));--muted: color-mix(in srgb, var(--fg) 60%, var(--bg))}*{box-sizing:border-box}html,body,#app{height:100%;margin:0;background:var(--bg);color:var(--fg)}body{overflow:hidden;-webkit-user-select:none;user-select:none}button,select{font:inherit;color:inherit;min-height:40px;border:1px solid var(--line);border-radius:8px;background:var(--bg);cursor:pointer}button{padding:6px 12px;touch-action:manipulation}button:hover{background:var(--surface)}button:active{transform:translateY(1px)}button:focus-visible,select:focus-visible{outline:3px solid var(--primary);outline-offset:2px}button:disabled{opacity:.4;cursor:default}#app{display:flex;flex-direction:column}.topbar{display:flex;align-items:center;gap:12px;padding:8px 12px;border-bottom:1px solid var(--line)}.topbar select{border:0;font-weight:650;min-width:0;flex:1}.topbar #solved{font:12px ui-monospace,monospace;color:var(--muted)}#reset{font-size:22px;width:40px;padding:0}main{display:flex;flex:1;min-height:0}.board-wrap{position:relative;flex:1;min-width:0;min-height:0;background-image:radial-gradient(var(--line) 1px,transparent 1px);background-size:24px 24px}canvas{display:block;width:100%;height:100%;touch-action:none}.board-caption{position:absolute;top:16px;left:20px;right:20px;display:flex;justify-content:space-between;pointer-events:none;font:10px ui-monospace,monospace;letter-spacing:2px;color:var(--muted)}aside{flex:0 0 250px;padding:20px 16px;border-left:1px solid var(--line);display:flex;flex-direction:column;gap:12px;background:var(--surface)}.shelf-title{font:10px ui-monospace,monospace;letter-spacing:2px;color:var(--muted);margin-bottom:8px}.add-row{display:flex;gap:8px}.add-row button{flex:1;text-align:left;display:flex;align-items:center;gap:8px;min-height:58px}.add-row small{margin-left:auto;color:var(--muted)}.ramp-icon{color:#b8793c;font-size:27px}.bumper-icon{color:#d87962;font-size:24px}.edit-row{display:flex;gap:4px}.edit-row select{min-width:0;width:100%;font-size:11px}.edit-row button{font-size:22px;padding:4px 10px}.nudges{display:flex;gap:4px;align-items:center}.nudges span{font-size:11px;color:var(--muted);margin-right:auto}.nudges button{padding:4px 9px}#hint{color:var(--muted);line-height:1.8;margin:4px 0;flex:1}.run-row{display:flex;gap:8px;flex-wrap:wrap}#drop{background:var(--primary);color:#fff;border:0;flex:1;font-weight:650;min-height:46px;white-space:nowrap}#retry{flex:1;white-space:nowrap}#status{font-size:11px;color:var(--muted);line-height:1.5;min-height:34px}#save{font-size:11px;border:0;background:transparent;min-height:28px;color:var(--muted)}#celebration{position:absolute;left:50%;top:45%;transform:translate(-50%,-50%);width:80%;text-align:center;background:var(--bg);border:1px solid var(--line);border-radius:12px;padding:24px;box-shadow:0 8px 40px #0002}#celebration[hidden]{display:none}#celebration strong{display:block;font-size:28px;font-family:Georgia,serif;font-style:italic}#celebration span{display:block;margin:12px 0;color:var(--muted)}#next{background:var(--primary);color:#fff}.too-small{display:none}@media(max-width:650px){.topbar{padding:4px 8px}main{flex-direction:column}aside{flex:0 0 auto;padding:8px;border-left:0;border-top:1px solid var(--line);gap:6px;display:grid;grid-template-columns:1fr 1fr}.shelf-title,.nudges,#hint,#save{display:none}.add-row{grid-column:1}.add-row button{min-height:44px;padding:4px 8px;font-size:11px;gap:4px}.edit-row{grid-column:2}.edit-row button{padding:4px 7px}.edit-row select{width:55px}.run-row{grid-column:1 / -1;flex-wrap:nowrap}#status{grid-column:1 / -1;min-height:16px;font-size:10px}.board-caption{top:12px;left:12px;right:12px;font-size:9px;letter-spacing:1px}#celebration{padding:16px}#celebration strong{font-size:23px}}@media(min-width:1000px){aside{flex-basis:290px;padding:28px 20px;gap:16px}.add-row button{min-height:70px}}@media(max-height:470px)and (min-width:651px){aside{padding:8px;gap:4px}.shelf-title,#hint,.nudges{display:none}.add-row button{min-height:44px}}@media(max-width:279px),(max-height:359px){#app{display:none}.too-small{display:flex;height:100%;padding:12px;align-items:center;justify-content:center;line-height:1.7;color:var(--muted)}}@media(max-width:650px){aside{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.add-row,.edit-row{min-width:0}.add-row button{min-width:0;justify-content:center;padding:4px}.add-row small{display:none}.ramp-icon,.bumper-icon{font-size:20px}.edit-row select{flex:1;width:0}.edit-row button{flex:0 0 28px;padding:4px}button,select{min-width:0}}
</style>
<meta name="napplet-type" content="impossible-machine">
<meta name="napplet-config-schema" content="{"$schema":"http://json-schema.org/draft-07/schema#","$version":1,"type":"object","additionalProperties":false,"properties":{"showTrail":{"type":"boolean","title":"Marble trail","description":"Show the current and previous marble paths to help tune your machine.","default":true}}}">
</head>
<body>
<div class="too-small">
A little more room to tinker?<br />Enlarge this pane to at least 280 ×
360.
</div>
<div id="app">
<div class="topbar">
<select id="level" aria-label="Choose puzzle">
<option value="0">01 · A gentle nudge</option>
<option value="1">02 · Across the gap</option>
<option value="2">03 · The long way</option>
<option value="3">∞ · Free play</option></select
><span id="solved" title="Solved puzzles">0 / 3</span
><button
id="reset"
title="Reset the entire arrangement"
aria-label="Reset arrangement"
>
↺
</button>
</div>
<main>
<div class="board-wrap">
<canvas
id="board"
aria-label="Physics workbench. Drag ramps and bumpers. Use the piece selector and movement buttons as an alternative."
></canvas>
<div class="board-caption">
<span id="phase">YOUR WORKBENCH</span
><span id="attempt">TAKE 01</span>
</div>
<div id="celebration" hidden>
<strong>Beautifully done.</strong
><span>One small marble. One brilliant machine.</span
><button id="next">Next puzzle →</button>
</div>
</div>
<aside>
<div class="shelf-title">MAKE IT MOVE</div>
<div class="add-row">
<button id="ramp">
<span class="ramp-icon">╱</span> Ramp <small>+</small></button
><button id="bumper">
<span class="bumper-icon">◉</span> Bumper <small>+</small>
</button>
</div>
<div class="edit-row">
<select id="piece" aria-label="Selected piece"></select
><button id="left" aria-label="Rotate ramp counterclockwise">
↶</button
><button id="right" aria-label="Rotate ramp clockwise">↷</button
><button id="remove" aria-label="Remove selected piece">×</button>
</div>
<div class="nudges" aria-label="Move selected piece">
<span>Move</span
><button data-dx="-10" aria-label="Move left">←</button
><button data-dy="-10" aria-label="Move up">↑</button
><button data-dy="10" aria-label="Move down">↓</button
><button data-dx="10" aria-label="Move right">→</button>
</div>
<p id="hint">
Drag a piece. Give it a tilt.<br />See where the marble goes.
</p>
<div class="run-row">
<button id="drop">▶ <span>Drop marble</span></button
><button
id="retry"
aria-label="Retry keeping arrangement"
title="Retry keeping arrangement"
>
↺ Retry
</button>
</div>
<output id="status" aria-live="polite"
>A little trial. A little error. A little magic.</output
><button id="save">Save progress</button>
</aside>
</main>
</div>
</body>
</html>
