Last active
January 28, 2026 04:53
-
-
Save rndmcnlly/f2e55933b22936d2d8d03e6ef0b4eaf0 to your computer and use it in GitHub Desktop.
mixed-initiative synthesis of lock and key gridworlds
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <style> | |
| body { | |
| background-color: white; /* Ensure the iframe has a white background */ | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!DOCTYPE html> | |
| <!-- Vibe-coded with Gambit v1.4 (https://bayleaf.chat/?model=gambit) --> | |
| <!-- DESIGN QUESTION: Does mixed-initiative ASP feel responsive enough for level sketching? --> | |
| <!-- DESIGN QUESTION: How do players recover from UNSAT states they create? --> | |
| <!-- Original prompt: Show me something comparable to wfc but with asp involving a lock and key constraint that you can reach the key from the top left corner and then use it to traverse a lock before exiting the bottom right corern of some tile-frequency constrained maze or whatever. It should also illustrate the mixed-initiative interaction pattern where me and the solve take turn modifying the design. I should be able to tap a cell to bring up a little menu where I can force the cell into a state (open, wall, lock, key) or leave it unconstrained. Unpinned cells are grayed or something to indicate their tentative status. We need to handle the UNSAT case. Maybe have an undo stack and a conspicuous undo button that we can use in that case. The solver is always trying to find a feasible solution, like in Tanagra. Responsive, mobile-friendly touch interface. Expanders for revealing the constraint problem (but not supporting direct edits). --> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <title>Lock & Key Designer</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: system-ui, sans-serif; | |
| background: #1a1a2e; | |
| color: #eee; | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 10px; | |
| touch-action: manipulation; | |
| } | |
| h1 { font-size: 1.2em; margin-bottom: 5px; color: #8be9fd; } | |
| .status { | |
| font-size: 0.85em; | |
| padding: 5px 10px; | |
| border-radius: 4px; | |
| margin-bottom: 10px; | |
| min-height: 1.5em; | |
| } | |
| .status.solving { background: #44475a; color: #f1fa8c; } | |
| .status.solved { background: #50fa7b33; color: #50fa7b; } | |
| .status.unsat { background: #ff555533; color: #ff5555; } | |
| .status.error { background: #ff555533; color: #ff7979; } | |
| .toolbar { | |
| display: flex; | |
| gap: 8px; | |
| margin-bottom: 10px; | |
| flex-wrap: wrap; | |
| justify-content: center; | |
| } | |
| button { | |
| background: #44475a; | |
| color: #f8f8f2; | |
| border: none; | |
| padding: 8px 16px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.9em; | |
| transition: all 0.15s; | |
| } | |
| button:hover { background: #6272a4; } | |
| button:disabled { opacity: 0.4; cursor: not-allowed; } | |
| button.undo { background: #bd93f9; color: #282a36; } | |
| button.undo:hover { background: #caa8ff; } | |
| .grid-container { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| .grid { | |
| display: grid; | |
| gap: 2px; | |
| background: #282a36; | |
| padding: 4px; | |
| border-radius: 8px; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.5); | |
| } | |
| .cell { | |
| width: 44px; | |
| height: 44px; | |
| border-radius: 4px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 1.4em; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| position: relative; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| } | |
| .cell:active { transform: scale(0.95); } | |
| /* Tile colors */ | |
| .cell.open { background: #44475a; } | |
| .cell.wall { background: #6272a4; } | |
| .cell.key { background: #f1fa8c; color: #282a36; } | |
| .cell.lock { background: #ffb86c; color: #282a36; } | |
| /* Pinned vs tentative */ | |
| .cell.tentative { opacity: 0.5; } | |
| .cell.tentative::after { | |
| content: ''; | |
| position: absolute; | |
| inset: 2px; | |
| border: 2px dashed rgba(255,255,255,0.3); | |
| border-radius: 3px; | |
| pointer-events: none; | |
| } | |
| .cell.pinned { box-shadow: inset 0 0 0 2px #50fa7b; } | |
| /* Start/Exit markers */ | |
| .cell.start::before { content: '▶'; position: absolute; top: 2px; left: 4px; font-size: 0.5em; color: #50fa7b; } | |
| .cell.exit::before { content: '◀'; position: absolute; bottom: 2px; right: 4px; font-size: 0.5em; color: #ff79c6; } | |
| /* Cell menu */ | |
| .cell-menu { | |
| position: fixed; | |
| background: #282a36; | |
| border-radius: 8px; | |
| padding: 4px; | |
| display: none; | |
| flex-direction: column; | |
| gap: 2px; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.6); | |
| z-index: 100; | |
| border: 1px solid #44475a; | |
| } | |
| .cell-menu.active { display: flex; } | |
| .cell-menu button { | |
| padding: 10px 20px; | |
| text-align: left; | |
| font-size: 1em; | |
| } | |
| .cell-menu button.selected { background: #6272a4; } | |
| /* Expandable ASP view */ | |
| details { | |
| width: 100%; | |
| max-width: 500px; | |
| margin-top: 15px; | |
| } | |
| summary { | |
| cursor: pointer; | |
| padding: 8px; | |
| background: #44475a; | |
| border-radius: 6px; | |
| font-size: 0.85em; | |
| } | |
| summary:hover { background: #6272a4; } | |
| pre { | |
| background: #282a36; | |
| padding: 10px; | |
| border-radius: 0 0 6px 6px; | |
| font-size: 0.7em; | |
| overflow-x: auto; | |
| max-height: 300px; | |
| overflow-y: auto; | |
| white-space: pre-wrap; | |
| word-break: break-all; | |
| } | |
| .legend { | |
| display: flex; | |
| gap: 12px; | |
| margin: 10px 0; | |
| flex-wrap: wrap; | |
| justify-content: center; | |
| font-size: 0.8em; | |
| } | |
| .legend-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .legend-swatch { | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 3px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.9em; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>🔑 Lock & Key Designer</h1> | |
| <div class="status solving" id="status">Initializing...</div> | |
| <div class="legend"> | |
| <div class="legend-item"><div class="legend-swatch" style="background:#44475a">·</div> Open</div> | |
| <div class="legend-item"><div class="legend-swatch" style="background:#6272a4">█</div> Wall</div> | |
| <div class="legend-item"><div class="legend-swatch" style="background:#f1fa8c;color:#282a36">🔑</div> Key</div> | |
| <div class="legend-item"><div class="legend-swatch" style="background:#ffb86c;color:#282a36">🔒</div> Lock</div> | |
| </div> | |
| <div class="toolbar"> | |
| <button class="undo" id="undoBtn" disabled>↩ Undo</button> | |
| <button id="clearBtn">Clear All Pins</button> | |
| <button id="reshuffleBtn">🎲 Reshuffle</button> | |
| </div> | |
| <div class="grid-container"> | |
| <div class="grid" id="grid"></div> | |
| </div> | |
| <div class="cell-menu" id="cellMenu"> | |
| <button data-type="open">· Open</button> | |
| <button data-type="wall">█ Wall</button> | |
| <button data-type="key">🔑 Key</button> | |
| <button data-type="lock">🔒 Lock</button> | |
| <button data-type="unpin">✕ Unpin</button> | |
| </div> | |
| <details> | |
| <summary>📜 View ASP Constraints</summary> | |
| <pre id="aspCode"></pre> | |
| </details> | |
| <script> | |
| // === CONFIGURATION === | |
| const WIDTH = 7; | |
| const HEIGHT = 5; | |
| const TILE_ICONS = { open: '·', wall: '█', key: '🔑', lock: '🔒' }; | |
| // === STATE === | |
| let pins = new Map(); // "x,y" -> tile type | |
| let solution = new Map(); // "x,y" -> tile type (from solver) | |
| let undoStack = []; | |
| let seed = Math.floor(Math.random() * 10000); | |
| let solving = false; | |
| // === DOM === | |
| const grid = document.getElementById('grid'); | |
| const status = document.getElementById('status'); | |
| const cellMenu = document.getElementById('cellMenu'); | |
| const undoBtn = document.getElementById('undoBtn'); | |
| const clearBtn = document.getElementById('clearBtn'); | |
| const reshuffleBtn = document.getElementById('reshuffleBtn'); | |
| const aspCode = document.getElementById('aspCode'); | |
| // === GRID SETUP === | |
| grid.style.gridTemplateColumns = `repeat(${WIDTH}, 44px)`; | |
| function createGrid() { | |
| grid.innerHTML = ''; | |
| for (let y = 0; y < HEIGHT; y++) { | |
| for (let x = 0; x < WIDTH; x++) { | |
| const cell = document.createElement('div'); | |
| cell.className = 'cell'; | |
| cell.dataset.x = x; | |
| cell.dataset.y = y; | |
| if (x === 0 && y === 0) cell.classList.add('start'); | |
| if (x === WIDTH-1 && y === HEIGHT-1) cell.classList.add('exit'); | |
| cell.addEventListener('click', onCellClick); | |
| grid.appendChild(cell); | |
| } | |
| } | |
| } | |
| function renderGrid() { | |
| const cells = grid.querySelectorAll('.cell'); | |
| cells.forEach(cell => { | |
| const x = parseInt(cell.dataset.x); | |
| const y = parseInt(cell.dataset.y); | |
| const key = `${x},${y}`; | |
| const isPinned = pins.has(key); | |
| const tile = isPinned ? pins.get(key) : (solution.get(key) || 'open'); | |
| cell.className = 'cell ' + tile; | |
| if (x === 0 && y === 0) cell.classList.add('start'); | |
| if (x === WIDTH-1 && y === HEIGHT-1) cell.classList.add('exit'); | |
| if (isPinned) { | |
| cell.classList.add('pinned'); | |
| } else { | |
| cell.classList.add('tentative'); | |
| } | |
| cell.textContent = TILE_ICONS[tile]; | |
| }); | |
| } | |
| // === CELL MENU === | |
| let activeCell = null; | |
| function onCellClick(e) { | |
| const cell = e.currentTarget; | |
| const x = parseInt(cell.dataset.x); | |
| const y = parseInt(cell.dataset.y); | |
| activeCell = { x, y, key: `${x},${y}` }; | |
| // Position menu near cell | |
| const rect = cell.getBoundingClientRect(); | |
| cellMenu.style.left = Math.min(rect.left, window.innerWidth - 150) + 'px'; | |
| cellMenu.style.top = Math.min(rect.bottom + 5, window.innerHeight - 250) + 'px'; | |
| // Highlight current selection | |
| const currentType = pins.get(activeCell.key); | |
| cellMenu.querySelectorAll('button').forEach(btn => { | |
| btn.classList.toggle('selected', btn.dataset.type === currentType); | |
| }); | |
| cellMenu.classList.add('active'); | |
| } | |
| cellMenu.addEventListener('click', (e) => { | |
| if (e.target.tagName === 'BUTTON' && activeCell) { | |
| const type = e.target.dataset.type; | |
| saveUndo(); | |
| if (type === 'unpin') { | |
| pins.delete(activeCell.key); | |
| } else { | |
| pins.set(activeCell.key, type); | |
| } | |
| cellMenu.classList.remove('active'); | |
| solve(); | |
| } | |
| }); | |
| document.addEventListener('click', (e) => { | |
| if (!cellMenu.contains(e.target) && !e.target.classList.contains('cell')) { | |
| cellMenu.classList.remove('active'); | |
| } | |
| }); | |
| // === UNDO === | |
| function saveUndo() { | |
| undoStack.push(new Map(pins)); | |
| if (undoStack.length > 50) undoStack.shift(); | |
| undoBtn.disabled = false; | |
| } | |
| undoBtn.addEventListener('click', () => { | |
| if (undoStack.length > 0) { | |
| pins = undoStack.pop(); | |
| undoBtn.disabled = undoStack.length === 0; | |
| solve(); | |
| } | |
| }); | |
| clearBtn.addEventListener('click', () => { | |
| if (pins.size > 0) { | |
| saveUndo(); | |
| pins.clear(); | |
| solve(); | |
| } | |
| }); | |
| reshuffleBtn.addEventListener('click', () => { | |
| seed = Math.floor(Math.random() * 10000); | |
| solve(); | |
| }); | |
| // === ASP SOLVER === | |
| function buildASP() { | |
| let asp = `% Lock & Key Maze Generator | |
| % Mixed-initiative: user pins constrain the solution space | |
| #const w=${WIDTH}. | |
| #const h=${HEIGHT}. | |
| cell(0..w-1, 0..h-1). | |
| tile(open; wall; key; lock). | |
| % === USER PINS === | |
| `; | |
| for (const [key, type] of pins) { | |
| const [x, y] = key.split(',').map(Number); | |
| asp += `pinned(${x},${y},${type}).\n`; | |
| } | |
| asp += ` | |
| % === TILE ASSIGNMENT === | |
| % Unpinned cells: choose exactly one tile | |
| 1 { place(X,Y,T) : tile(T) } 1 :- cell(X,Y), not pinned(X,Y,_). | |
| % Pinned cells: forced assignment | |
| place(X,Y,T) :- pinned(X,Y,T). | |
| % === START/EXIT CONSTRAINTS === | |
| % Start (0,0) must be traversable, not lock | |
| :- place(0,0,wall). | |
| :- place(0,0,lock). | |
| % Exit (w-1,h-1) must be plain open | |
| :- place(w-1,h-1,wall). | |
| :- place(w-1,h-1,lock). | |
| :- place(w-1,h-1,key). | |
| % === EXACTLY ONE KEY AND ONE LOCK === | |
| :- #count{X,Y : place(X,Y,key)} != 1. | |
| :- #count{X,Y : place(X,Y,lock)} != 1. | |
| % === ADJACENCY === | |
| adj(X,Y,X+1,Y) :- cell(X,Y), cell(X+1,Y). | |
| adj(X,Y,X-1,Y) :- cell(X,Y), cell(X-1,Y). | |
| adj(X,Y,X,Y+1) :- cell(X,Y), cell(X,Y+1). | |
| adj(X,Y,X,Y-1) :- cell(X,Y), cell(X,Y-1). | |
| % === REACHABILITY WITHOUT KEY === | |
| % Can traverse open or key tiles, but NOT walls or locks | |
| reach0(0,0). | |
| reach0(X2,Y2) :- reach0(X1,Y1), adj(X1,Y1,X2,Y2), | |
| not place(X2,Y2,wall), not place(X2,Y2,lock). | |
| % Key must be reachable without crossing the lock | |
| :- place(X,Y,key), not reach0(X,Y). | |
| % === REACHABILITY WITH KEY === | |
| % After getting key, can also pass through lock | |
| reach1(X,Y) :- place(X,Y,key), reach0(X,Y). | |
| reach1(X2,Y2) :- reach1(X1,Y1), adj(X1,Y1,X2,Y2), | |
| not place(X2,Y2,wall). | |
| % Exit must be reachable after getting the key | |
| :- not reach1(w-1,h-1). | |
| % === TILE FREQUENCY (variety) === | |
| % Walls: 15-35% of cells | |
| :- #count{X,Y : place(X,Y,wall)} < w*h*15/100. | |
| :- #count{X,Y : place(X,Y,wall)} > w*h*35/100. | |
| #show place/3. | |
| `; | |
| return asp; | |
| } | |
| async function solve() { | |
| if (solving) return; | |
| solving = true; | |
| status.textContent = '⏳ Solving...'; | |
| status.className = 'status solving'; | |
| const asp = buildASP(); | |
| aspCode.textContent = asp; | |
| const args = encodeURIComponent(`--outf=2 -n 1 --sign-def=rnd --seed=${seed}`); | |
| try { | |
| const response = await fetch( | |
| `https://proofdoku.com/clingo?tag=gambit-lock-key&args=${args}`, | |
| { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'text/gringo' }, | |
| body: asp | |
| } | |
| ); | |
| const result = await response.json(); | |
| if (result.Result === 'UNSATISFIABLE') { | |
| status.textContent = '❌ UNSAT - No valid level! Try undo or unpin cells.'; | |
| status.className = 'status unsat'; | |
| // Keep showing last valid solution (grayed out) | |
| } else if (result.Result === 'SATISFIABLE' && result.Call?.[0]?.Witnesses?.[0]) { | |
| const atoms = result.Call[0].Witnesses[0].Value; | |
| solution.clear(); | |
| for (const atom of atoms) { | |
| const match = atom.match(/place\((\d+),(\d+),(\w+)\)/); | |
| if (match) { | |
| const [, x, y, tile] = match; | |
| solution.set(`${x},${y}`, tile); | |
| } | |
| } | |
| status.textContent = '✅ Valid level found'; | |
| status.className = 'status solved'; | |
| } else { | |
| status.textContent = '⚠️ Unexpected solver result'; | |
| status.className = 'status error'; | |
| } | |
| } catch (err) { | |
| status.textContent = '❌ Solver error: ' + err.message; | |
| status.className = 'status error'; | |
| } | |
| renderGrid(); | |
| solving = false; | |
| } | |
| // === INIT === | |
| createGrid(); | |
| solve(); | |
| </script> | |
| </body> | |
| </html> | |
| <script> | |
| </script> | |
| </body> | |
| </html> | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment