This is an original MIT game, including its rules, level generator, interface and procedural ribbon artwork. No upstream game code or third-party artwork is used.
The theme was inspired by the University of Stuttgart’s August 11, 2026 account of a programmable DNA-origami nanosyringe. That research describes reversible sliding driven by DNA strand displacement. Here, symbolic ribbons exchange places when one right-hand key matches another left-hand dock. These are invented puzzle rules, not a representation of experimental chemistry, DNA sequences, transport rates or medical outcomes.
University research report · Research paper
Breaklock (331 stars observed November 5, 2026; MIT; repository pushed October 21, 2026) was a rejected candidate because SkillSafe already hosts it. This is a research reference only, not this game’s upstream source. No code, art, level layouts, prompts or assets were reused. Other popular candidates were duplicates, server-dependent or lacked clear asset rights, so this run used the original-game fallback.
A strand has a left dock and right key. The held right key must equal the resident left dock to allow an exchange. The old resident becomes the held strand. Restore every labelled ribbon to its matching chamber; the remaining ribbon stays free. Reverse breadth-first search enumerates every state that reaches the target, giving shortest-route hints. All forward-reachable states in all eight experiments can return to the solution. Gold requires a route no longer than par without hints. Any completed experiment earns a petal.
All runtime resources are bundled locally; no network, accounts, downloads or server required. Device saves use localStorage. Audio is synthesized only after opt-in.
MIT License Copyright (c) 2026 Toehold contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101f29"><title>Toehold · Petal Exchange</title><link rel="icon" href="./icon.svg"><link rel="stylesheet" href="./style.css"></head><body><main> <header><a class="wordmark" href="./">TOEHOLD <span> / PETAL EXCHANGE</span></a><button id="guide">How to play <kbd>G</kbd></button></header> <section class="intro"><p class="eyebrow">A LITTLE WORLD OF MOLECULAR MACHINES</p><h1>One strand in.<br><em>Another story out.</em></h1><p>Trade tiny ribbons of light. Restore the chamber, one careful exchange at a time.</p></section> <section class="workbench" aria-label="Strand exchange puzzle"><div class="benchhead"><div><label for="level">EXPERIMENT</label><select id="level" aria-label="Choose experiment"></select></div><div class="counter"><strong id="moves">0</strong><span>exchanges</span></div><div class="counter"><strong id="par">3</strong><span>gold route</span></div></div> <div class="table"><div class="handzone"><div class="smallcaps">YOUR FREE STRAND</div><div id="hand"></div><p class="handnote">Its <b>right-hand key</b> fits the<br><b>left-hand dock</b> of a lit chamber.</p><div class="legend"><span>○ Sun</span><span>◇ Tide</span><span>△ Leaf</span><span>✦ Dusk</span></div></div><div class="boardzone"><div class="smallcaps">THE CHAMBER <span>• restore each letter to its outline</span></div><div id="board"></div></div></div> <div id="status" role="status" aria-live="polite"></div><div class="tools"><button id="undo">↶ Undo <kbd>U</kbd></button><button id="hint">✧ Nudge <kbd>H</kbd></button><button id="reset">↻ Restart <kbd>R</kbd></button><button id="sound" aria-pressed="false">Sound off <kbd>M</kbd></button><button id="next" class="primary" hidden>Next experiment →</button></div></section> <section class="journal"><div><p class="eyebrow">YOUR FIELD NOTES</p><h2>Eight small discoveries.</h2><p id="recordtext">Your discoveries are saved on this device.</p></div><div id="records" aria-label="Experiment records"></div></section> <footer><p>An original puzzle inspired by programmable DNA nanomachines. Fictional rules; not a molecular simulation.</p><a href="./source.html">Source, inspiration & MIT license</a></footer> <dialog id="help"><button id="close" aria-label="Close guide">×</button><p class="eyebrow">THE ART OF EXCHANGE</p><h2>Follow the free strand.</h2><p>Every ribbon has a <b>left dock</b> and a <b>right key</b>, shown as symbols and colours. You hold one free ribbon.</p><ol><li>Match your free ribbon’s <b>right key</b> to a chamber ribbon’s <b>left dock</b>. Matching chambers glow and say “Exchange”.</li><li>Click that chamber (or press its number). Your ribbon moves in; its old ribbon becomes the new free strand.</li><li>Restore <b>A to chamber A, B to B</b>, and so on. The spare ribbon belongs in your hand. A correct ribbon may need to leave again!</li></ol><p>Every experiment has a verified route. <b>Nudge</b> highlights one next exchange on a shortest route from your current position. Undo is unlimited. Gold means finishing in the stated route length without a nudge; any finish earns a petal.</p><p class="keyline">1–6 exchange · H nudge · U undo · R restart · N next · G guide · M sound</p><p class="fine">Progress stays in this browser. Sounds begin only when enabled. Select any experiment at any time. Recent DNA-origami research inspired the theme; the puzzle rules and artwork are original.</p></dialog> <script type="module" src="./app.js"></script></main></body></html>
:root{color-scheme:dark;--ink:#e7ece7;--muted:#91a8ad;--line:#30464e;--gold:#e5bd79;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:radial-gradient(ellipse at 90% 0,#243e43 0,transparent 45%),radial-gradient(ellipse at 5% 60%,#21333b 0,transparent 40%),#101f29;min-height:100vh}main{max-width:1120px;padding:28px 32px;margin:auto}button,select{font:inherit;color:inherit}button{cursor:pointer;border:1px solid var(--line);background:#20353e;padding:11px 15px;border-radius:9px;transition:background .15s,transform .15s}button:hover:not(:disabled){background:#314953}button:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid var(--gold);outline-offset:4px}button:disabled{cursor:default;opacity:.48}kbd{font-size:10px;color:#adc0c2;border:1px solid #526067;padding:1px 4px;margin-left:6px;border-radius:3px}header{display:flex;justify-content:space-between;align-items:center;gap:15px}.wordmark{font-size:16px;letter-spacing:3px;font-weight:750;text-decoration:none;color:#f0e0bc}.wordmark span{font-size:10px;letter-spacing:2px;color:#9cb4b8}header button{font-size:12px}.intro{position:relative;padding:42px 0 30px}.intro:after{content:"";position:absolute;right:90px;top:45px;width:180px;height:160px;border:1px solid #64847b44;border-radius:48% 52% 30% 70%;transform:rotate(-30deg);box-shadow:28px 2px 0 -2px #536c6533,-25px -12px 0 -3px #80845b22;pointer-events:none}.eyebrow,.smallcaps{font-size:10px;letter-spacing:2.2px;font-weight:650;color:#b6c9bf}.eyebrow{color:var(--gold)}h1{font:normal clamp(36px,5vw,56px)/1.06 Georgia,serif;letter-spacing:-1.7px;margin:14px 0}h1 em{font-weight:normal;color:#cfdeb9}p{color:var(--muted);font-size:14px;line-height:1.65}.intro>p:last-child{max-width:490px;margin-bottom:0}.workbench{border:1px solid #476066;border-radius:19px;background:#152a32;box-shadow:0 20px 80px #06101855;overflow:hidden}.benchhead{display:flex;align-items:center;padding:20px 27px;gap:27px;border-bottom:1px solid var(--line);background:#193039}.benchhead>div:first-child{flex:1;min-width:0}.benchhead label{display:block;font-size:9px;letter-spacing:2px;color:#b0c6bb;margin-bottom:6px}select{background:#193039;border:0;max-width:100%;font:normal 21px Georgia,serif;padding:2px 18px 2px 0;cursor:pointer}.counter{text-align:right}.counter strong{display:block;font:normal 25px Georgia,serif;color:#ead4a7}.counter span{font-size:10px;color:#a4b8b9;white-space:nowrap}.table{display:grid;grid-template-columns:235px 1fr;gap:26px;padding:27px;position:relative;background:radial-gradient(circle at 10% 100%,#3d615520,transparent 60%)}.handzone{border-right:1px solid var(--line);padding-right:26px;display:flex;flex-direction:column}.smallcaps{letter-spacing:1.8px;font-size:9px;margin-bottom:15px}.smallcaps span{color:#829ea4;font-size:9px;letter-spacing:.3px}#hand{padding-top:20px}.handnote{font-size:12px;line-height:1.6;color:#a3b9b9;margin:25px 0 10px}.handnote b{font-weight:550;color:#dce6da}.legend{display:flex;flex-wrap:wrap;gap:9px;font-size:10px;color:#8fa7aa;margin-top:auto;padding-top:12px}.boardzone{min-width:0}#board{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px}.slot{position:relative;background:#14252d;border:1px solid #334b51;border-radius:13px;padding:12px;min-width:0;min-height:164px;text-align:left;opacity:1!important}.slot:disabled{opacity:1}.slot:not(:disabled){border-color:#83ab92;box-shadow:inset 0 0 28px #668d7220}.slot:not(:disabled):hover{background:#24433f;transform:translateY(-2px)}.slot.nudged{outline:3px solid #e5bd79;outline-offset:2px}.slot.correct{background:#28443d80}.slothead{display:flex;align-items:center;justify-content:space-between;font-size:9px;letter-spacing:1.1px;color:#97afb0;margin-bottom:13px}.correct .slothead{color:#c1d4a8}.target{border-top:1px dashed #536b6870;margin-top:14px;padding-top:9px;font-size:10px;color:#91a8ab;display:flex;justify-content:space-between}.target b{color:#c9d4b4;font-weight:500}.strand{position:relative;display:grid;grid-template-columns:1fr 30px 1fr;align-items:center;padding:10px 5px;border-radius:22px;background:linear-gradient(130deg,#77959445,#496c6555);box-shadow:inset 0 1px 0 #daead01f,0 3px 7px #071a2266;isolation:isolate}.strand:before{content:"";position:absolute;z-index:-1;left:19%;right:19%;top:40%;height:20%;background:repeating-linear-gradient(90deg,#cbd4b938 0 2px,transparent 2px 7px);border-top:1px solid #b9c9a540;border-bottom:1px solid #b9c9a540}.end{font-size:25px;line-height:1;display:flex;justify-content:center;align-items:center;border:1px solid currentColor;border-radius:50%;height:37px;width:37px;margin:auto;background:#17323b}.e0{color:#edc985}.e1{color:#9fd6df}.e2{color:#bddda2}.e3{color:#d6b6df}.strandname{font:normal 23px Georgia,serif;color:#fff0cf;text-align:center;text-shadow:0 1px 10px #ffffff40}.endlabels{display:flex;justify-content:space-between;padding:5px 8px 0;font-size:8px;color:#9cb4b6;letter-spacing:.6px}.ribbonhint{text-align:center;font-size:10px;color:#dec99e;margin-top:14px}.handzone .strand{padding:15px 8px;grid-template-columns:1fr 37px 1fr}.handzone .end{height:47px;width:47px;font-size:31px}.handzone .strandname{font-size:32px}.exchange{display:block;color:#d2e9b5;font-size:10px;margin-top:12px}.slot:disabled .exchange{color:#78969c}#status{border-top:1px solid var(--line);padding:16px 27px;min-height:57px;background:#1a3037;font-size:13px;line-height:1.6;color:#d3dfcc}#status.win{color:#edca89;background:#3a443432}.tools{display:flex;flex-wrap:wrap;gap:9px;padding:0 27px 20px;background:#1a3037}.tools button{font-size:11px;padding:10px 13px}.primary{background:#c9d8af;color:#183036;border-color:#c9d8af!important;font-weight:700}.primary:hover{background:#e0eacb!important}.journal{display:flex;justify-content:space-between;gap:30px;align-items:center;padding:36px 0 20px}.journal h2{font:normal 26px Georgia,serif;margin:9px 0}.journal p{font-size:12px;margin:6px 0}.journal .eyebrow{font-size:9px}#records{display:grid;grid-template-columns:repeat(8,1fr);gap:8px}.record{width:35px;height:44px;border-radius:50% 50% 35% 35%;padding:0;font-size:11px;background:#1d343b;color:#819a9e}.record.petal{color:#cce0bb;background:#365443;border-color:#6d926d}.record.gold{background:#62593b;border-color:#ceb671;color:#ffdfa0}footer{display:flex;gap:20px;justify-content:space-between;align-items:start;border-top:1px solid var(--line);padding-top:14px}footer p{font-size:10px;margin:0;max-width:570px}footer a{color:#b6c5ac;font-size:10px;line-height:1.7}dialog{background:#1d333b;border:1px solid #607774;border-radius:20px;color:var(--ink);width:min(590px,calc(100vw - 30px));padding:34px;max-height:90vh;overflow:auto}dialog::backdrop{background:#06131bd9;backdrop-filter:blur(8px)}dialog h2{font:normal 34px Georgia,serif;margin:10px 0 20px}dialog b{color:#dfe6cd}dialog p,dialog li{font-size:13px;line-height:1.75;color:#b2c5c5}dialog li{padding:5px 0}dialog ol{padding-left:20px}#close{float:right;padding:1px 10px;font-size:24px}.keyline{padding:12px;background:#10262e;border-radius:8px}.fine{font-size:11px!important}[hidden]{display:none!important}@media(max-width:820px){main{padding:20px}.intro{padding-top:30px}.intro:after{right:15px;width:120px;height:120px;opacity:.5}.table{grid-template-columns:185px 1fr;gap:18px;padding:20px}.handzone{padding-right:18px}#board{grid-template-columns:repeat(2,minmax(0,1fr))}.slot{min-height:160px}.journal{display:block}#records{display:flex;margin-top:17px}footer{display:block}footer a{display:inline-block;margin-top:10px}}@media(max-width:520px){main{padding:18px 13px}.wordmark{font-size:13px;letter-spacing:2px}.wordmark span{display:block;font-size:8px;margin-top:6px;letter-spacing:1px}header button{font-size:11px;padding:8px}kbd{display:none}h1{font-size:39px}.intro:after{width:75px;height:75px;top:48px;right:10px;opacity:.4}.intro{padding:24px 0}.intro>p:last-child{font-size:12px;max-width:300px}.eyebrow{font-size:8px;letter-spacing:1.5px}.benchhead{padding:16px;gap:13px}select{font-size:17px}.counter strong{font-size:21px}.counter span{font-size:8px}.table{display:block;padding:17px 15px}.handzone{border-right:0;border-bottom:1px solid var(--line);padding:0 0 17px;margin-bottom:17px;display:grid;grid-template-columns:1fr 1fr;gap:5px 14px}.handzone .smallcaps{grid-column:1/-1;margin-bottom:5px}#hand{padding-top:0}.handnote{margin:5px 0;font-size:10px;align-self:center}.legend{grid-column:1/-1;padding-top:5px;gap:12px;font-size:9px}.handzone .strand{padding:8px 3px;grid-template-columns:1fr 24px 1fr}.handzone .end{width:31px;height:31px;font-size:23px}.handzone .strandname{font-size:25px}.ribbonhint{font-size:9px;margin-top:8px}.endlabels{font-size:7px}.smallcaps{margin-bottom:11px;font-size:8px;letter-spacing:1.1px}.smallcaps span{display:block;margin-top:5px;font-size:8px}#board{gap:10px}.slot{min-height:153px;padding:11px}.slothead{font-size:8px}.strand{grid-template-columns:1fr 25px 1fr;padding:10px 2px}.end{width:33px;height:33px;font-size:23px}.strandname{font-size:24px}.target{font-size:9px}.exchange{font-size:9px}#status{padding:13px 16px;font-size:12px}.tools{padding:0 15px 17px;gap:7px}.tools button{padding:9px 10px;font-size:10px}#next{flex:1}.journal{padding-top:25px}#records{gap:7px}.record{width:33px;height:40px}dialog{padding:24px}.journal h2{font-size:24px}}@media(prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}}
export function key(s){return s.join(',')}
export function legal(level,s,i){return Number.isInteger(i)&&i>=0&&i<s.length-1&&level.edges[s.at(-1)][1]===level.edges[s[i]][0]}
export function exchange(level,s,i){if(!legal(level,s,i))return null;const t=s.slice();[t[i],t[t.length-1]]=[t.at(-1),t[i]];return t}
export function solved(s){return s.every((v,i)=>v===i)}
const cache=new Map();
export function routes(level){if(cache.has(level))return cache.get(level);const goal=level.edges.map((_,i)=>i),d=new Map([[key(goal),0]]),q=[goal];for(let n=0;n<q.length;n++){let s=q[n],h=s.at(-1);for(let i=0;i<s.length-1;i++){if(level.edges[s[i]][1]!==level.edges[h][0])continue;let t=s.slice();[t[i],t[t.length-1]]=[h,t[i]];let k=key(t);if(!d.has(k)){d.set(k,d.get(key(s))+1);q.push(t)}}}cache.set(level,d);return d}
export function hint(level,s){const d=routes(level),dist=d.get(key(s));if(!dist)return null;for(let i=0;i<s.length-1;i++){const t=exchange(level,s,i);if(t&&d.get(key(t))===dist-1)return i}return null}
export const levels=[{"name": "First foothold", "edges": [[0, 1], [1, 2], [2, 0], [1, 0]], "start": [3, 2, 0, 1], "par": 3, "solution": [1, 2, 0], "reachable": 8, "diameter": 6}, {"name": "Return current", "edges": [[0, 1], [1, 0], [0, 2], [2, 0], [1, 2]], "start": [3, 0, 2, 4, 1], "par": 5, "solution": [1, 3, 0, 3, 0], "reachable": 120, "diameter": 28}, {"name": "The side door", "edges": [[0, 1], [1, 2], [2, 0], [0, 2], [2, 1]], "start": [0, 4, 1, 2, 3], "par": 7, "solution": [3, 0, 2, 0, 2, 0, 1], "reachable": 120, "diameter": 25}, {"name": "A quiet loop", "edges": [[0, 0], [0, 1], [1, 2], [2, 0], [1, 0]], "start": [2, 4, 0, 3, 1], "par": 8, "solution": [0, 3, 2, 0, 3, 2, 3, 1], "reachable": 120, "diameter": 19}, {"name": "Petal relay", "edges": [[0, 1], [1, 2], [2, 0], [0, 2], [2, 1], [1, 0]], "start": [2, 5, 1, 0, 4, 3], "par": 10, "solution": [0, 3, 1, 0, 3, 1, 2, 1, 2, 0], "reachable": 720, "diameter": 16}, {"name": "Still water", "edges": [[0, 0], [0, 1], [1, 1], [1, 2], [2, 0], [2, 2]], "start": [4, 2, 3, 5, 1, 0], "par": 11, "solution": [4, 1, 2, 0, 1, 0, 1, 4, 0, 1, 3], "reachable": 720, "diameter": 20}, {"name": "Fourth wavelength", "edges": [[0, 1], [1, 2], [2, 3], [3, 0], [0, 2], [2, 0], [1, 3]], "start": [6, 2, 1, 3, 5, 0, 4], "par": 14, "solution": [1, 3, 1, 4, 5, 2, 3, 1, 2, 3, 1, 2, 3, 0], "reachable": 5040, "diameter": 24}, {"name": "The last bloom", "edges": [[0, 1], [1, 2], [2, 3], [3, 0], [0, 2], [2, 1], [1, 0]], "start": [0, 3, 1, 6, 2, 5, 4], "par": 16, "solution": [4, 1, 0, 2, 1, 0, 4, 0, 4, 2, 3, 0, 4, 2, 3, 0], "reachable": 5040, "diameter": 23}];
import{levels}from'./levels.js';import{legal,exchange,solved,routes,key,hint}from'./engine.js';
const $=s=>document.querySelector(s),symbols=['○','◇','△','✦'],names=['Sun','Tide','Leaf','Dusk'],letters='ABCDEFG';let index=0,state=[],history=[],moves=0,usedHint=false,nudged=null,message='',sound=false,audio=null,records={},saved=null;
try{saved=JSON.parse(localStorage.getItem('toehold-petal-v1')||'null');records=saved?.records||{}}catch{}
const valid=(s,n)=>Array.isArray(s)&&s.length===n&&s.every(Number.isInteger)&&s.slice().sort((a,b)=>a-b).every((v,i)=>v===i);
function save(){try{localStorage.setItem('toehold-petal-v1',JSON.stringify({index,state,moves,usedHint,history,records,sound}))}catch{}}
function tone(win=false){if(!sound)return;try{audio??=new(window.AudioContext||window.webkitAudioContext)();audio.resume();const t=audio.currentTime;[0,...(win?[1,2]:[])].forEach((v)=>{let o=audio.createOscillator(),g=audio.createGain();o.type='sine';o.frequency.value=[440,554.37,659.25][v];g.gain.setValueAtTime(0,t+v*.09);g.gain.linearRampToValueAtTime(.05,t+v*.09+.015);g.gain.exponentialRampToValueAtTime(.001,t+v*.09+.3);o.connect(g).connect(audio.destination);o.start(t+v*.09);o.stop(t+v*.09+.35)})}catch{}}
function ribbon(id,level){const e=level.edges[id];return `<div class="strand" data-strand="${id}"><span class="end e${e[0]}" aria-label="${names[e[0]]} dock">${symbols[e[0]]}</span><span class="strandname">${letters[id]}</span><span class="end e${e[1]}" aria-label="${names[e[1]]} key">${symbols[e[1]]}</span></div><div class="endlabels"><span>LEFT DOCK</span><span>RIGHT KEY</span></div>`}
function render(){const l=levels[index],win=solved(state),hand=state.at(-1),tail=l.edges[hand][1];$('#level').value=index;$('#moves').textContent=moves;$('#par').textContent=l.par;$('#hand').innerHTML=ribbon(hand,l)+`<div class="ribbonhint">Your key: ${symbols[tail]} ${names[tail]}</div>`;$('#board').innerHTML=state.slice(0,-1).map((id,i)=>{let ok=legal(l,state,i)&&!win,correct=id===i;return `<button class="slot ${correct?'correct':''} ${nudged===i?'nudged':''}" data-slot="${i}" data-id="${id}" ${ok?'':'disabled'} aria-label="Chamber ${letters[i]}, holds ${letters[id]}, dock ${names[l.edges[id][0]]}${ok?', exchange available':''}"><div class="slothead"><span>CHAMBER ${letters[i]}</span><span>${i+1}</span></div>${ribbon(id,l)}<div class="target"><span>Outline ${letters[i]}</span><b>${correct?'✓ Restored':'Needs '+letters[i]}</b></div><span class="exchange">${win?'Bloom restored':ok?'↔ Exchange':'Key does not match'}</span></button>`}).join('');$('#board').dataset.state=key(state);$('#undo').disabled=!history.length;$('#hint').disabled=win;$('#next').hidden=!win||index===levels.length-1;$('#status').className=win?'win':'';let count=state.filter((v,i)=>i<state.length-1&&v===i).length;$('#status').textContent=message||(win?`Experiment restored in ${moves} exchanges. ${!usedHint&&moves<=l.par?'A golden petal!':'A new petal for your field notes.'}`:`${count} of ${state.length-1} outlines restored. Match ${symbols[tail]} ${names[tail]} to a left dock to exchange.`);$('#sound').setAttribute('aria-pressed',sound);$('#sound').innerHTML=`Sound ${sound?'on':'off'} <kbd>M</kbd>`;$('#records').innerHTML=levels.map((_,i)=>`<button class="record ${records[i]?.gold?'gold':records[i]?'petal':''}" data-level="${i}" aria-label="Experiment ${i+1}, ${records[i]?.gold?'gold petal':records[i]?'completed':'unexplored'}">${records[i]?.gold?'✧':records[i]?'✿':i+1}</button>`).join('');let done=Object.keys(records).length;$('#recordtext').textContent=`${done} / 8 petals · ${Object.values(records).filter(v=>v.gold).length} gold. Saved on this device.`;save()}
function load(i){index=i;state=levels[i].start.slice();history=[];moves=0;usedHint=false;nudged=null;message='';render()}
function play(i){if(solved(state))return;let l=levels[index],next=exchange(l,state,i);if(!next){message='That dock has a different symbol. Look for a lit chamber.';render();return}history.push({state:state.slice(),moves});state=next;moves++;nudged=null;message='';let win=solved(state);if(win){const old=records[index];records[index]={best:Math.min(old?.best??Infinity,moves),gold:!!old?.gold||(!usedHint&&moves<=l.par)}}tone(win);render()}
function undo(){let prev=history.pop();if(!prev)return;state=prev.state;moves=prev.moves;nudged=null;message='Exchange rewound. Try another route.';render()}
function nudge(){nudged=hint(levels[index],state);usedHint=true;message=nudged==null?'No next exchange is needed.':`Try chamber ${letters[nudged]} (${nudged+1}). It brings you one exchange closer on a shortest route.`;render()}
$('#board').onclick=e=>{const b=e.target.closest('[data-slot]');if(b)play(+b.dataset.slot)};$('#records').onclick=e=>{const b=e.target.closest('[data-level]');if(b)load(+b.dataset.level)};$('#level').innerHTML=levels.map((l,i)=>`<option value="${i}">${i+1}. ${l.name}</option>`).join('');$('#level').onchange=e=>load(+e.target.value);$('#undo').onclick=undo;$('#hint').onclick=nudge;$('#reset').onclick=()=>load(index);$('#next').onclick=()=>load(Math.min(7,index+1));$('#sound').onclick=()=>{sound=!sound;tone();render()};$('#guide').onclick=()=>$('#help').showModal();$('#close').onclick=()=>$('#help').close();$('#help').onclick=e=>{if(e.target===$('#help')){const r=$('#help').getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)$('#help').close()}};
document.addEventListener('keydown',e=>{if(e.ctrlKey||e.metaKey||e.altKey||e.repeat)return;const k=e.key.toLowerCase();if($('#help').open){if(k==='g')$('#help').close();return}if(e.target.matches('select,input,textarea'))return;if(/^[1-6]$/.test(k)){e.preventDefault();play(+k-1)}else if(k==='u')undo();else if(k==='h')nudge();else if(k==='r')load(index);else if(k==='n'&&solved(state)&&index<7)load(index+1);else if(k==='g')$('#help').showModal();else if(k==='m')$('#sound').click()});
if(saved&&Number.isInteger(saved.index)&&saved.index>=0&&saved.index<8&&valid(saved.state,levels[saved.index].edges.length)&&routes(levels[saved.index]).has(key(saved.state))){index=saved.index;state=saved.state;moves=Math.max(0,Number(saved.moves)||0);usedHint=!!saved.usedHint;sound=!!saved.sound;history=Array.isArray(saved.history)?saved.history.filter(h=>valid(h.state,state.length)&&Number.isFinite(h.moves)).slice(-500):[];render()}else load(0);
{
"title": "Toehold \u00b7 Petal Exchange",
"slug": "toehold-petal-exchange",
"tags": [
"github-source",
"game"
],
"license": "MIT",
"original_game": true,
"upstream_game_repository": null,
"github_reference": {
"url": "https://github.com/maxwellito/breaklock",
"role": "rejected candidate research only; no code or assets reused",
"stars_observed": 331,
"license": "MIT",
"pushed_at": "2026-10-21T07:11:09Z"
},
"inspiration": [
"https://www.uni-stuttgart.de/en/university/news/all/Programming-membrane-transport/",
"https://www.nature.com/articles/s41565-026-02249-3"
],
"description": "Original single-held-strand, symbol-constrained exchange puzzle. Eight exactly solver-checked experiments, shortest-route nudges, unlimited undo, keyboard/pointer controls, optional locally synthesized sound and device-local petals. Fictional rules, not a molecular simulation."
}import json,pathlib,collections,random
R=pathlib.Path('/private/tmp/skillsafe-run-1106a');(R/'dist').mkdir(exist_ok=True)
sets=[[(0,1),(1,2),(2,0),(1,0)],[(0,1),(1,0),(0,2),(2,0),(1,2)],[(0,1),(1,2),(2,0),(0,2),(2,1)],[(0,0),(0,1),(1,2),(2,0),(1,0)],[(0,1),(1,2),(2,0),(0,2),(2,1),(1,0)],[(0,0),(0,1),(1,1),(1,2),(2,0),(2,2)],[(0,1),(1,2),(2,3),(3,0),(0,2),(2,0),(1,3)],[(0,1),(1,2),(2,3),(3,0),(0,2),(2,1),(1,0)]]
names=['First foothold','Return current','The side door','A quiet loop','Petal relay','Still water','Fourth wavelength','The last bloom']
levels=[]
for k,edges in enumerate(sets):
n=len(edges);goal=tuple(range(n));dist={goal:0};q=collections.deque([goal])
while q:
s=q.popleft();h=s[-1]
for i in range(n-1):
if edges[s[i]][1]!=edges[h][0]:continue
v=list(s);v[-1],v[i]=v[i],v[-1];v=tuple(v)
if v not in dist:dist[v]=dist[s]+1;q.append(v)
md=max(dist.values());want=min(md,[3,5,7,8,10,11,14,16][k]);candidates=[s for s,d in dist.items() if d==want];random.Random(740+k).shuffle(candidates)
start=next((s for s in candidates if sum(s[i]!=i for i in range(n-1))>=min(n-1,3)),candidates[0]);sol=[];s=start
while dist[s]:
for i in range(n-1):
if edges[s[-1]][1]==edges[s[i]][0]:
v=list(s);v[-1],v[i]=v[i],v[-1];v=tuple(v)
if dist.get(v)==dist[s]-1:sol.append(i);s=v;break
else:raise Exception('no route')
levels.append(dict(name=names[k],edges=edges,start=start,par=want,solution=sol,reachable=len(dist),diameter=md))
print(k+1,'par',want,'states',len(dist),'diam',md,start,sol)
(R/'levels.json').write_text(json.dumps(levels,indent=2));(R/'dist'/'levels.js').write_text('export const levels='+json.dumps(levels)+';\n')