Skip to content
misty-stepPublic

About

Liminal — a daily solo intersection puzzle. Find a real noun that satisfies every condition. Outside/Close/Inside feedback, five guesses, daily + practice.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

60 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Liminal

A daily solo puzzle about the in-between. Three labeled circles overlap in four places: the center (inside all three) and three gaps (inside two circles, outside the third). Fill all four with real things. No guess limit: your score is how many guesses and how long it took. Every word lands where it belongs on the board; many answers are right in every region.

Live: liminal.mistystep.io (Cloudflare Worker liminal, deployed from master).

Modes

  • Today: one puzzle per UTC day, same for every player, numbered from #1 on 2026-09-23 (src/lib/liminal/daily.ts). GET /api/today serves the scheduled puzzle for today's date from the D1 schedule table and falls back to the bundled deck rotation when the schedule has no entry for the date. Only the server chooses that fallback, after a successful lookup: an unreadable schedule (or a production deployment without the D1 binding) answers 503, and the page shows unlabeled circles with "Try again" rather than a puzzle other players might not see.
  • Archive: after finishing, "Play #N" opens the day before; "Back to today" returns. GET /api/puzzle/<number> serves any past number and 404s future ones, so nobody can play tomorrow early. Progress is saved per puzzle.

Literal-object and wordplay puzzles apply their own sense rules in the judge rubrics; the board does not label them.

Rules and feedback

Each guess is judged per circle: inside, on the line (close), or outside, and placed on the board accordingly. A guess fills a region when it lands there cleanly (no circle on the line) and the region is still empty (src/lib/liminal/regions.ts). Words on a line, in one circle, or outside every circle stay on the board as information. A word whose position cannot show its verdict exactly carries explicit marks (src/lib/liminal/placement.ts).

The game ends only when all four regions are filled. The clock counts thinking time: it pauses while the judge considers a word (network latency never reaches the score), while the how-to is open, and while the tab is hidden. Progress and clock time are saved per puzzle (localStorage schema liminal.v2); v1 progress is not migrated. If the browser denies persistent storage, the current tab remains playable using in-memory progress; restoring it after reload is not guaranteed. See the persistence capability decision.

A guess is spent exactly when it receives a judgment (authored or live). Refusals spend nothing: a circle label, a word already on the board, empty or oversized input, judge outages, rate limits, and the calibration gate.

Sharing

The share text (src/lib/liminal/share.ts) is a header, one colored square per guess, the score, and a link:

Liminal #1
🟪⬜🟩🟧⬛
5 guesses, 0:22
https://liminal.mistystep.io/s/<code>

Squares are the place each guess filled (purple, green, orange gap; black center) or white when it filled nothing. A perfect four-guess board adds 🎯. The text never contains words or circle labels. The link's code carries the player's words; /s/<code> shows the result with each word hidden until tapped (noindex, no server state). Touch devices use the native share sheet; desktop copies to the clipboard.

Judgment architecture

  • Authored deck judgments (src/lib/liminal/deck.ts) are deterministic, offline, and always available. Every region of every puzzle ships with verified answers and held-out answers; src/lib/liminal/__tests__/deck.test.ts enforces that each authored answer lands in its region.
  • Semantic service (Jev) covers answers outside the authored lists. One authored question per condition (a Choice over descriptive levels; Noul yes/no only as a legacy fallback) via the server route /api/judge. Providers: TypeSafe native (TYPESAFE_API_KEY) or OpenRouter Decisions (OPENROUTER_API_KEY, model typesafe/jev-1.13).
  • Probabilities are not intensities. A Choice is scored yes + partly / 2 and banded (inside >= 0.65, close >= 0.35, else outside) in judgment.ts. There is no uncertainty refusal: a torn judge lands a word on the line, where it fills nothing.
  • Judgments are versioned and cached (judgmentKey: puzzle, condition, answer, model, prompt version) so duplicate submissions never reroll a judgment.
  • A service outage is honest: the guess is not consumed, no score is fabricated, and the UI says the judge is unavailable.

Security posture

  • Plain HTTP requests to liminal.mistystep.io are redirected to the same HTTPS URL by the host-scoped edge rule, using 308 to preserve methods and bodies. Its exact configuration and rollback live in the repo; no zone-wide HTTPS setting or HSTS is needed.
  • Credentials stay server-side (route handler only; never shipped to the client).
  • Player text is untrusted data: bounded length (120 chars), rate-limited, time-boxed (8 s), and passed to the judge as state data — never as instructions.
  • /api/report is size-capped and rate-limited; failures are reported honestly.

Production foundations

  • GET /api/health checks the deck and the D1 binding. Staging and production fail closed when durable storage is missing; local development reports that storage is intentionally not configured.
  • The official PostHog browser SDK measures the stories in SPEC. CI binds the approved US project's public ingestion key as NEXT_PUBLIC_POSTHOG_KEY; app=liminal, environment and the existing release SHA partition queries. Anonymous IDs are host-scoped with 30-day cookies; sessions/retries use SDK defaults. Only templated routes and bounded enums/counts leave the app. Person profiles, autocapture, recording, IP/GeoIP and private content are disabled; DNT is honored. Filter environment=production and is_synthetic=false before counting people. Missing capture is unknown, not zero usage. Never use a personal API credential in a browser/build.
  • New clients no longer post analytics to D1. POST /api/events, its strict legacy schema and existing rows remain only for old tabs/code rollback; they are not a second current collector or the new usage source. This change neither migrates nor deletes accepted data.
  • Sentry is disabled unless a DSN is supplied. Server, edge, and browser capture use explicit environment and release values, sampled traces, source maps for release builds, sendDefaultPii: false, and a scrubber that removes request data, headers, query strings, identity, extras, and breadcrumb text.
  • ops/production-foundations.json registers health, events, and the liminal Sentry project for the approved production-only game-operations triage path.
  • bun run foundation:check executes the repository-owned brand, health, telemetry, storage, Sentry, and CI contract.

Development

bun install
bun run dev        # local
bun run format:check
bun run lint
bun run foundation:check
bun run test       # vitest: engine, deck, boundaries, storage, routes, privacy
bun run type-check
bun run build:cf   # reproducible OpenNext/Cloudflare deploy artifact

BASE=http://127.0.0.1:<port> bun e2e/product-analytics.ts exercises a local production-built Worker against the approved live PostHog sink: rejection, outage/recovery, all five story names, private share-route templating, restored completion and a separate DNT game. It refuses non-loopback apps and records only synthetic UUIDs/properties, never the ingestion token. Its transport receipt is not stored-event proof: retrieve those exact UUIDs through the authorized project API separately. The standard artifact QA blocks all sinks.

Puzzle pipeline. The judge key (OPENROUTER_API_KEY or TYPESAFE_API_KEY) scores words; generation and the audit's player model bill a separate LIMINAL_GENERATOR_API_KEY and refuse to run on the judge key, so drafting can never drain the key the live game judges with:

bun run puzzles:daily -- --store file --buffer 7 --budget 1   # fill upcoming dates (local files)
bun run puzzles:generate -- --concepts 60 --keep 12 --budget 2   # propose, screen, expand, critique
bun run puzzles:audit                               # critic on published puzzles
bun run puzzles:promote content/candidates/<id>.json
bun run validate:live -- --only <id> --mark-calibrated

Publishing is automatic. .github/workflows/daily-puzzles.yml runs puzzles:daily --store d1 nightly: it fills missing dates from tomorrow up to a week ahead, publishing only puzzles that clear the publish bar and a zero-miss live calibration. Scheduled rows are never updated, and a date that has begun can be neither inserted nor deleted (D1 triggers), so the puzzle for a day never changes once that day has begun; a future puzzle can still be pulled before it airs. A date nobody filled in time stays on the deck fallback all day. See AGENTS.md for the gates.

Deploy

Reviewed changes deploy automatically from master. CI builds and bundles once, walks the stories against those exact bytes with disposable local D1, then retains a checksummed artifact. Deploy app consumes that successful CI run without rebuilding. It refuses stale/foreign/failed/skipped candidates, preserves Worker secrets and variables, and reads back health plus the full source revision. A failed promotion restores the prior healthy native Worker version and checks provider and HTTP identity. It never restores data or secrets. See the artifact-promotion decision.

Repository CLOUDFLARE_API_TOKEN is the shared narrow deployment binding; NEXT_PUBLIC_SENTRY_DSN is the existing public browser DSN build variable. Database changes remain separate: this code-only deploy neither applies remote migrations nor invokes puzzle generation/calibration. Existing data is preserved.

Contract:

  • Cloudflare Worker + custom domain liminal.mistystep.io → { "pattern": "liminal.mistystep.io", "custom_domain": true }.
  • Worker secrets: OPENROUTER_API_KEY (scoped) or TYPESAFE_API_KEY, plus SENTRY_DSN when monitoring is activated.
  • Worker vars: LIMINAL_ENVIRONMENT, SENTRY_ENVIRONMENT, JEV_MODEL=typesafe/jev-1.13, and JEV_DECISIONS_URL=https://openrouter.ai/api/alpha/decisions.
  • Release build vars: SENTRY_RELEASE and NEXT_PUBLIC_SENTRY_RELEASE are the exact candidate SHA; client Sentry activation also needs the public DSN and explicit environment. Supply NEXT_PUBLIC_SENTRY_DSN in the build shell; production builds reject missing browser capture or release identity. Source-map upload is not configured in this CI adoption; source-map custody is separate from artifact promotion.
  • Build: bun run build:cf (OpenNext, open-next.config.ts); deploy: bun run deploy:cf (wrangler --env production).
  • Durable store: D1 liminal-judgments (binding LIMINAL_DB) retains first-writer-wins judgments keyed by the versioned judgment keys, holds append-only answer reports and privacy-safe product events, and the daily schedule (migrations 0003_schedule.sql and 0004_schedule_started_immutable.sql, required by /api/health). Additive schema lives in migrations/. Apply migrations before deploying.
  • Daily generation (GitHub Actions): repository secrets LIMINAL_JUDGE_API_KEY, LIMINAL_GENERATOR_API_KEY (must differ), and LIMINAL_DAILY_D1_TOKEN, plus variable CLOUDFLARE_ACCOUNT_ID. The token is the account-owned liminal-daily-d1 (D1 Write only; local copy in pass at workstation/LIMINAL_DAILY_D1_TOKEN), never the workstation admin token.

Known limitations (this slice)

  • Live calibration. The gate is scripts/live-matrix.ts: every region answer, every held-out answer, and hostile input per puzzle. Deck 2026-09-23.1 under prompt liminal-judge-2026-09-23.1 passed 132/132 rows (84 answers, 36 held-out, 12 hostile) in evidence/live-matrix-2026-09-23T20-17-24-426Z.json; the run before it missed one row ("sock" lands on the head line) and that answer was removed. History and the banded-rule decision: evidence/calibration-findings.md.
  • The deck was rebuilt for regions. Kitchen Well, Made and Taken, and Pass or Fail could not fill all four regions with the live judge (at least one region had no clean answer) and were retired. Bath Vessel was kept.
  • A torn judge costs a guess. Banding replaces the old uncertainty refusal, so a word the judge splits on lands on a line and is spent. The report path ("Disagree?" on any word) is the escape valve.
  • Crowding. A second word for an already-filled pair region can be pushed off it; such words show explicit marks rather than a misleading position.
  • No give-up, by decision. With no guess limit there is no reveal or show-answers path (operator decision, 2026-09-24; US-001); the board stays open until all four places are filled.
  • The semantic judge is unit-tested against mocked transports; live behavior is proven by the live matrix at every deck/prompt version bump.
  • No accounts and no multiplayer (by design). Product events are anonymous, first-party, and intentionally exclude player-entered text.

About

Liminal — a daily solo intersection puzzle. Find a real noun that satisfies every condition. Outside/Close/Inside feedback, five guesses, daily + practice.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages