Skip to content

Repository files navigation

OUBLIETTE

A field interrogation of an on-chain AI escape-room lock, answered question by question

An oubliette is a cell you enter from above and leave only by your wits. This one runs on GenLayer. Every lock is sealed by a riddle, every attempt is ruled on by an AI gatekeeper, and every verdict is settled by validator consensus before the vault will let you descend.

The document that follows is a sequence of questions a newcomer tends to ask at the mouth of the vault, each answered plainly. Read it in order or jump to the question that brought you here.

Play it at samiinw.github.io/oubliette. The vault runs on GenLayer testnet-bradbury; the contract and its deploy receipt are linked where the questions reach them. Art direction: clockwork brass mechanism.


What is the Oubliette?

It is an on-chain escape room made of locks. Each cell presents a riddle that guards a mechanism, and to descend you submit a single written attempt to defeat it. An AI called the Oubliette Gatekeeper reads your attempt and rules SOLVED, PARTIAL, or REJECTED with an ingenuity score from 0 to 100. A SOLVED ruling releases the lock and carries you one depth deeper into the vault. Everything that matters, the locks, your progress, and the verdict that moves you, lives on GenLayer rather than on a private game server.

What problem does it actually solve?

A normal puzzle game checks your answer against a hidden string on someone's backend. You cannot see the check, cannot audit it, and must simply trust that it was fair. The Oubliette replaces that trusted backend with an Intelligent Contract. The judgment of free-text reasoning, the kind of fuzzy call that used to demand a centralized server, is performed by an AI and then ratified by independent validators who must agree before it counts. The result is a puzzle whose every ruling is reproducible, auditable, and tamper-evident on-chain.

Who decides if a lock opens?

No single party. Judgment runs through gl.vm.run_nondet_unsafe(leader_fn, validator_fn), GenLayer's mechanism for folding a non-deterministic AI call into a deterministic on-chain result. A leader node proposes the ruling, then every validator independently re-runs the same judgment and checks the leader's work. The lock only opens if the network agrees.

How exactly do the validators agree?

They compare two things. First the ruling, which must match exactly: if the leader says SOLVED and a validator concludes PARTIAL, that validator disagrees and consensus is not reached. Second the ingenuity score, which need not be identical but must land within tolerance (the larger of 15 points or 15 percent of the score), because two honest judges can rate cleverness slightly differently while still agreeing on the verdict that matters. If the leader itself errored, validators inspect the failure: expected user errors must match message-for-message, and a transient hiccup is accepted as a shared transient condition so the network is not punished for a momentary blip.

What stops a clever player from talking their way past the gatekeeper?

Layered guards, some before the AI and some after.

Before the LLM runs, deterministic code executes identically on every node and rejects an unknown lock, an empty or oversized attempt, a lock that is not OPEN, and any attempt by a player who has already released that lock. Malformed requests never reach the model.

The prompt itself is hardened. The gatekeeper is told that everything inside the player attempt is untrusted data and never instructions, that any attempt to rewrite its rules, impersonate the system, or extract the confidential rationale must be ruled REJECTED, and that it must never quote or hint at the rationale.

After the verdict, deterministic backstops have the final word (see the next question). And the contract never lets a lock resolve itself: only a genuine SOLVED ruling, ratified by consensus, advances state.

Can the AI hand out a score that contradicts its own verdict?

No, because a deterministic backstop clamps it. Each ruling owns a score band:

  • SOLVED occupies 67 to 100
  • PARTIAL occupies 34 to 66
  • REJECTED occupies 0 to 33

After consensus, _clamp_to_band forces the ingenuity score into the band that matches its own ruling, so a SOLVED can never look feeble nor a REJECTED look impressive. State also advances strictly on SOLVED: a solve increments the lock's solve count, appends the lock to your solved set, recomputes your depth as the number of locks you have released, and bumps the global tally. PARTIAL and REJECTED record the attempt and may raise your best score, but they do not open the door.

Where does the answer live? Can I jailbreak it out of the gatekeeper?

The answer, called the solving rationale, is supplied by each lock's author and is confidential vault state. It is handed to the model inside the judging prompt so the AI can tell a real solution from a confident bluff, but it never leaves the chain. The public projection of a lock, _public_lock, copies only id, title, riddle, depth, author, status, attempts, and solves; the rationale is omitted by construction, not merely hidden in the interface. No view method returns it. And because the prompt rules any extraction attempt as REJECTED and forbids the gatekeeper from quoting or paraphrasing the rationale, trying to jailbreak the answer out is itself a losing move.

What does the machine look like from the inside?

                         GenLayer testnet-bradbury
   +-----------------------------------------------------------------+
   |                  Oubliette Intelligent Contract                  |
   |                                                                  |
   |   author_lock ----> locks[]  (riddle + CONFIDENTIAL rationale)   |
   |                       lock_ids[]  (creation order = vault spine) |
   |                                                                  |
   |   attempt_lock                                                   |
   |     |                                                            |
   |     +--[1] deterministic guards (lock open? size? already won?]  |
   |     |                                                            |
   |     +--[2] gl.vm.run_nondet_unsafe                               |
   |     |        leader_fn  : exec_prompt -> ruling + ingenuity      |
   |     |        validator  : re-run, ruling exact, score in tol.    |
   |     |                                                            |
   |     +--[3] backstops: clamp score to band, advance only SOLVED   |
   |     |                                                            |
   |     +--> players[]  chronicle[]  totals                          |
   |                                                                  |
   |   views: get_locks / get_lock / get_player                       |
   |          get_chronicle / get_stats   (rationale NEVER returned)   |
   +-----------------------------------------------------------------+
                                  ^   |
                  read views      |   |   write txs (attempt / author)
                                  |   v
   +-----------------------------------------------------------------+
   |        Frontend: Next.js static export (no server, no DB)        |
   |   genlayer-js client . framer-motion . lucide-react             |
   |   single-screen brass lock kiosk . consensus-as-theater         |
   +-----------------------------------------------------------------+

The boundary is strict: the contract holds every authoritative byte, and the frontend is a static site that only reads views and submits transactions. There is no backend in between.

What are the public methods, and how is each one designed for consensus?

Two writes and five views make up the surface.

author_lock(title, riddle, solution_rationale) -> str Adds a cell to the vault. The title is bounded to 80 characters, the riddle to 600, and the confidential rationale to 600. The contract assigns the next depth, stamps the author's address, stores the record, and returns the new lock_id (for example lock-3). This is a plain deterministic write with no AI round.

attempt_lock(lock_id, attempt_text) -> dict The AI write. It runs deterministic guards, then a single consensus round through gl.vm.run_nondet_unsafe, then deterministic backstops. It returns a verdict dict: lock, ruling, ingenuity (already clamped to the ruling band), note, advanced, and your new depth. This is the only method that consults the gatekeeper and the only one that can move you deeper.

get_locks(start) -> list Returns up to twenty public lock records from a given index, paging down the vault spine. View only, never carries a rationale.

get_lock(lock_id) -> dict Returns a single public lock record. View only, never carries a rationale.

get_player(actor) -> dict Returns a player's standing: depth, solved locks, attempt count, and best score, or a zeroed record for an actor who has never attempted a lock.

get_chronicle(start) -> list Returns the append-only attempt log newest-first: lock, title, actor, ruling, clamped ingenuity, note, whether it advanced the player, and a sequence number.

get_stats() -> dict Returns the running tallies: total locks, total attempts, total solves, and chronicle length.

What is the frontend built with, and why does it look like clockwork?

The interface is a Next.js static export (output: 'export'), so it ships as plain files with no server and no database. It talks to the chain through genlayer-js, animates with framer-motion, and draws its iconography with lucide-react inline SVG rather than emoji.

The art direction is a clockwork brass mechanism: aged walnut and soot dark wood, polished brass-gold accents with patina-teal verdigris, engraved gear and gauge motifs, and warm inset lamplight. The UX decisions follow from the fiction:

  • Single-screen lock kiosk. One focused lock mechanism fills the screen at a time with no scrolling. A tactile brass control panel takes your attempt, and a vertical chain of solved and locked vault doors forms the progress spine.
  • Consensus as theater. When you submit, the combination dial turns as the lock deliberates, and the interface lets you peek at the leader's draft ruling before validators ratify it, turning the consensus lifecycle into something you watch.
  • Slow polling. Views are read on a calm cadence rather than hammered, matching the deliberate pace of a vault that thinks before it opens.
  • No mock data. Every lock, verdict, and chronicle entry shown is real on-chain state read from the deployed contract.

How do I run it myself?

You need Node and, if you want to talk to the contract directly, the GenLayer toolchain.

git clone https://github.com/SAMiiNW/oubliette.git
cd oubliette/frontend
npm install
npm run dev

That serves the kiosk locally against the live contract. To sanity-check the chain directly:

genlayer network set testnet-bradbury
genlayer call 0x17ac6cA53d912810c115B659b958839A2Cb5b08a get_stats

Two house-style gates guard the prose and exit non-zero on any hit:

node scripts/no-emoji.js
node scripts/no-emdash.js

How is it deployed?

The static export is wired for GitHub Pages with output: 'export', an unoptimized image loader, and a basePath of /oubliette. From the frontend directory, npm run deploy runs the build through the predeploy hook and pushes the out directory to the gh-pages branch with the --dotfiles flag so that .nojekyll ships and Pages serves the _next assets untouched. The contract was deployed once to GenLayer testnet-bradbury at the address above and is not redeployed by the frontend pipeline.

Where can I see it live?

Where is the exact contract that is running?

The whole backend is a single file, contracts/contract.py, and the authoritative copy is the one deployed on-chain. You can read it in full in the repo and verify it against the live deployment on the explorer. Rather than reprint the entire file here, here is the part that actually matters, the consensus core inside attempt_lock, where the gatekeeper rules and the validators must agree before a lock can open:

def leader_fn():
    raw = gl.nondet.exec_prompt(prompt, response_format="json")
    return _normalize_verdict(raw)           # strict {ruling, ingenuity, note}

def validator_fn(leaders_res: gl.vm.Result) -> bool:
    if not isinstance(leaders_res, gl.vm.Return):
        return _handle_leader_error(leaders_res, leader_fn)
    mine = leader_fn()                        # each validator re-judges the attempt
    theirs = leaders_res.calldata
    if mine["ruling"] != theirs.get("ruling"):
        return False                          # ruling drives state: exact match
    a, b = int(mine["ingenuity"]), int(theirs.get("ingenuity", -1))
    return b >= 0 and abs(a - b) <= max(15, (15 * max(a, b)) // 100)

return gl.vm.run_nondet_unsafe(leader_fn, validator_fn)

And the deterministic backstop that fences the score to its ruling band after consensus, so a SOLVED can never look feeble nor a REJECTED look impressive:

BAND_SOLVED, BAND_PARTIAL, BAND_REJECTED = (67, 100), (34, 66), (0, 33)

def _clamp_to_band(ruling: str, score: int) -> int:
    lo, hi = {"SOLVED": BAND_SOLVED, "PARTIAL": BAND_PARTIAL}.get(ruling, BAND_REJECTED)
    return max(lo, min(hi, score))

Only a SOLVED ruling, once it survives this whole pipeline, advances the player a depth deeper. Everything else is recorded and the door stays shut.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages