← Return to Toehold

Toehold · Petal Exchange

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.

Inspiration, not a simulation

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

GitHub search disclosure

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.

Rules and verification

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.

Complete readable source

LICENSE.txt
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.
index.html
<!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 &amp; 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>
style.css
: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}}
engine.js
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}
levels.js
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}];
app.js
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);
metadata.json
{
  "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."
}
Level generator (Python)
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')