[ Parable build guide ]
How Oubliette was built
An escape-theatre company whose whole pitch is a lock you have to pick. The signature is a four-dial combination lock built in plain JavaScript — drag, click or arrow-key each dial to the symbol its line of verse names, and a concealed invitation springs open. No canvas, no libraries, no framework.
The idea
A real escape room sells one feeling: you have to earn the way out. A brochure site throws that away the moment it lists prices. So the homepage doesn't describe the puzzle — it is one. The hero is a working padlock with four rotating dials and a riddle cut into the stone. Solve it and the site hands you a secret word for a room that isn't on the menu. It turns "premium and mysterious" from an adjective into something a visitor actually does with their hands.
The stack
Deliberately plain. One index.html, one styles.css, one main.js.
No framework, no bundler, no CDN beyond Google Fonts — so the page has zero runtime dependencies and
ships straight to GitHub Pages. Type is Cinzel (a lapidary Roman capital with exactly
the right gothic-theatre weight for headings and the inscription), Crimson Pro for body
prose, and Space Mono for the ticket-stub meta — tiers, run times, escape rates.
Signature technique — the combination lock
Each dial is a role="spinbutton" with six faces. Dragging rotates the engraved ring in
real time by the pointer's swept angle; on release it snaps to the nearest face. Every change re-checks
the four values against a target the markup never contains, and the moment all four line up the lock
opens exactly once.
// six faces per dial; the hidden truth lives only here
const TARGET = [2, 1, 3, 4]; // Moon · Key · Hourglass · Raven
const STEP_DEG = 360 / SYMBOLS.length;
// angular drag: rotate the ring by the swept angle, snap on release
d.addEventListener('pointermove', (e) => {
if (!drag || drag.id !== e.pointerId) return;
let dd = angleAt(e.clientX, e.clientY) - drag.prev;
if (dd > 180) dd -= 360; else if (dd < -180) dd += 360; // shortest arc
drag.acc += dd; drag.prev += dd;
ring.style.transform = `rotate(${drag.startVal * STEP_DEG + drag.acc}deg)`;
const nv = ((drag.startVal + Math.round(drag.acc / STEP_DEG)) % N + N) % N;
if (nv !== values[i]) { values[i] = nv; render(i, false); check(); }
});
const check = () => {
if (!opened && values.every((v, i) => v === TARGET[i])) open();
};
The dial art is all CSS and inline SVG: a radial-gradient "iron" disc, an SVG ring whose one oxblood
tick makes the rotation legible, and the current symbol swapped into the centre. The shackle is a single
stroked SVG path that lifts and tilts on a springy cubic-bezier(.34,1.56,.64,1) when the
latch gives.
Details that matter
- Three ways to turn a dial. Pointer drag for feel, a plain click to advance one face, and arrow keys (plus Home/End) once a dial is focused — so the puzzle is fully solvable by keyboard, not just mouse.
- The answer isn't in the DOM.
TARGETlives in JavaScript, and the dials start on a deliberately-wrong scramble — no "correct" attribute to inspect, so solving it means reading the verse. - Reduced motion solves it for you. Under
prefers-reduced-motionthe dials render already on the answer, the reveal is shown with no animation, and the candle flicker freezes to one steady frame. - The invitation is gated behind
.js. The reveal panel ishiddenin the markup and only shown by script — so with JavaScript off the page is still fully legible; it just keeps its secret. - Cheap candlelight. The ambient glow is two blurred radial gradients on a slow CSS
flickerkeyframe — norequestAnimationFrameloop, so it costs the GPU almost nothing and pauses itself when the tab is hidden.
The booking form is a demo — it validates and confirms in-place but sends nothing. Wire it to your own endpoint (Formspree, a serverless function, a real booking API) before taking money for a room that doesn't exist yet.
Ship it on GitHub Pages
Nothing to build. Push the folder and point Pages at the root.
gh repo create bswxyz/oubliette --public --source . --push
gh api --method POST /repos/bswxyz/oubliette/pages -f 'source[branch]=main' -f 'source[path]=/'
Relative paths and a .nojekyll file mean it serves from the project subpath without a
single config change. That's the whole point of the static build — the lock works the same on a laptop
at file:// as it does on a live Pages URL.