← Back to Oubliette

[ 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.

static · no build stepvanilla JSCinzel / Crimson Pro / Space Monolight + darkreduced-motion aware

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

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.

← Back to Oubliette