A daily noir detective case lives inside a Reddit post. Every player's dossier is censored differently: a deterministic deal hands each viewer a few unredacted lines out of ~40 clue shards that the crowd doesn't have yet. Nobody can solve the case alone โ the comment thread becomes the evidence board where the subreddit collectively un-redacts the truth, flags contradictions, and votes an accusation before the verdict ceremony. A judge who opens the post isn't evaluating a game; within 30 seconds they're personally holding a line 400 strangers need.
It is not trivia (closed-world deduction over fictional evidence โ no outside knowledge helps) and not collaborative storytelling (one authored ground truth, a win/lose verdict). There is zero runtime AI: all 3 launch cases are handcrafted and pass an offline solvability linter. In an anti-AI-slop hackathon, that is the whole point, stated out loud.
- RECEIVE โ open the case folder (
CASE #17 ยท DAY 3 ยท 61% UNREDACTED). One black bar is yours: tap it and the redaction peels off to reveal your line ("the pawn ticket was dated the 14th โ two days AFTER the fire"). - READ THE BOARD โ evidence cards other players filed; contradiction pairs glow red where both sides are on the board (the red string is computed, not moderated).
- FILE โ one tap posts your line as a typeset evidence-card comment, under
your username if you consent (asUser
SUBMIT_COMMENT) or via the app account otherwise. The case meter ticks up; the ACCUSE bars shift. - ACCUSE / RETURN โ stake season points on one suspect (locked in a single transaction, one accusation per case). At the scheduled hour the verdict ceremony crowns the earliest correct accusers and cites the cards that cracked it; a new case drops at 00:00.
The population-elasticity valve: after case-hour 12 a scheduler drips the single highest-information unfiled shard onto the public board every hour, so even a 30-member sub closes every case and a judge arriving mid-case always lands in a live investigation. A solo Cold Case Archive replays closed cases against the recorded solve timeline โ no liveness dependency for judges.
The witnessable "I am needed" beat (seeded Case #17). The demo seed leaves the
crowd-favorite watchman standing at exactly 61% โ the crowd has already
cleared the widow and the rival, and the whole board is stuck arguing about him.
Filing your reserved pivot shard (the pawn ticket) ticks the meter 61 โ 63%,
strikes the watchman, and lights the second red string โ in one tap. That beat
is deterministic (core/demoSeed.ts), proven at build time (lint LD), and
locked by tests, so a lone cold judge witnesses their own impact.
src/client/ React, DOM/CSS only โ paper folder, typewriter type, redaction-peel
src/server/ Hono over the Devvit runtime
core/ PURE engine: hash, dealer, deduction, drip, verdict, meter,
contradictions, filters, cardMarker, time, demoSeed (no platform imports)
store.ts the ONLY Redis-touching layer (hashes + zsets + watch/multi/exec)
serialize.ts the I2 boundary โ builds client responses, never touches truth
routes/ thin adapters: /api/*, /internal/{cron,triggers,menu}/*
tools/case-compiler/ offline YAML โ sealed bundle + solvability linter
cases/*.yaml the authored cases (compiled to cases/compiled/*.bundle.json)
flowchart TD
subgraph Authoring [offline]
Y[cases/*.yaml] --> CC[case compiler + linter] --> B[sealed bundles]
end
B -->|devvit upload assets| R
subgraph Client [React webview]
D[Dossier + peel] -->|/api/my-shards| S
E[Evidence board] -->|/api/board| S
F[File card] -->|/api/file| S
G[Accuse] -->|/api/accuse| S
H[Verdict + Archive] -->|/api/verdict, /api/archive| S
E <-->|realtime tiles| RT[realtime]
end
subgraph Server [Devvit Node]
S[Hono] --> R[(Redis)]
C1[cron case-drop 00:00] --> R
C2[cron drip hourly] --> R
C3[cron verdict 21:00] --> RED[Reddit API]
T1[onComment] --> R
T2[onPostCreate] --> R
M[menu: Seed/Bonus/Approve/Hide] --> R
S -->|asUser SUBMIT_COMMENT| RED
end
Full Redis schema (case:{id}:public vs case:{id}:truth, shards/board/card
hashes, pivot/board/accuse/rank:season/rep:cited zsets) and endpoint tables are in
ARCHITECTURE.md. No plain redis lists/sets, empty
fetch allowlist, no runtime AI โ enforced by design.
| # | Invariant | Where it lives |
|---|---|---|
| I1 | Deal is pure + deterministic โ same viewer always sees the same lines | core/dealer.ts + first-write-wins persistence in store.dealFor |
| I2 | No endpoint returns undealt shard text or any truth field | truth split at compile time; serialize.ts is the only response builder |
| I3 | Pivot pool drains before duplication โ every fresh viewer (every judge) gets a board-absent shard while pivots remain | pivot:{id} zset drained head-first in store.dealFor |
| I4 | One accusation per (user, case), stake locked | store.accuse under watch/multi/exec |
A case is one YAML file (schema: tools/case-compiler/types.ts, guide:
cases/README.md). In brief:
suspects(โฅ3),docs(โฅ2) whoselinesare eithertext:or ashard:ref,shards(โฅ20) each{id, doc, text, supports:[factIds]},facts(โฅ4) โ atomic truths, each established by ANY one of its supporting shards being on the board,eliminationsโ per non-culprit suspect, โฅ2 paths (a path is a conjunction of factIds; any complete path strikes the suspect),contradictions(โฅ2) annotated shard pairs,pivots(โฅ1) reserved for first-seen accounts, and atruthblock (culprit,motive,summary,reveal[]). The culprit has no elimination entry โ they can never be struck.
The compiler splits the sealed bundle into a public half (src/shared) and a
server-only truth half (src/server/cases/types.ts) so truth cannot be
serialized to a client by construction.
npm run lint:cases runs three levels over every case (tools/case-compiler/lint.ts):
- L1 solvability โ the culprit is uneliminable; every other suspect is eliminable by โฅ2 shard-disjoint combinations; and a seeded Monte-Carlo of 1,000 random 60% deals all reach the truth (all non-culprits struck). The MC uses the same deduction engine as the runtime, so gameplay and lint can never diverge.
- L2 drama โ โฅ2 annotated contradiction pairs; no orphan shards (every shard participates in some elimination path).
- L3 safety โ profanity / link / real-user-resemblance (
u/,r/) filter over all case text; sealed bundle โค 200KB. - LD demo โ when a case names a demo
reserve:suspect, prove the magic moment: on the planned ~61% seed the reserved crowd-favorite is still standing, and filing a reserved pivot shard strikes them. The "I am needed" beat is a build guarantee, not a hope โ same deduction engine as L1.
Current status (npm run lint:cases):
โ case-017 "The Larchmont Fire" โ 49 shards, 4 suspects, MC 1000/1000, bundle 16.3KB
โ case-018 "The Halloway Vault" โ 40 shards, 4 suspects, MC 1000/1000, bundle 13.3KB
โ case-019 "The Gilt Cage" โ 40 shards, 4 suspects, MC 1000/1000, bundle 13.7KB
269 vitest tests across 17 files (npm test), all green, against the pure
cores + an in-memory Redis stub that implements the real RedisClient surface
(including watch/multi/exec optimistic concurrency) and Hono routes with
@devvit/web/server mocked out end-to-end:
| file | n | covers |
|---|---|---|
| store.test.ts | 37 | deal determinism (I1), pivot drain (I3), accusation escrow under contention (I4), seed determinism, reserve-aware seed + witnessable strike, file/drip/verdict idempotency, degenerate/partial-data edge cases |
| routes/api.test.ts | 37 | all /api/* endpoints end-to-end (case/my-shards/file/board/accuse/verdict/archive), consent-gated comment fallback, litContradiction + elimination deltas, best-effort realtime |
| routes/internal.test.ts | 32 | scheduler crons (drop/drip/verdict), the onComment trigger reconciliation, mod-menu actions, idempotency + best-effort Reddit side-effects |
| linter.test.ts | 28 | Monte-Carlo per case, disjointness, L2/L3, LD demo magic-moment proof, negative mutations |
| drip.test.ts | 28 | information-gain ordering, hour-12 gate, 10/100/1000-player closure sims |
| deduction.test.ts | 15 | fact/path/elimination/truth-reached per authored case |
| hash.test.ts | 14 | fnv1a / mulberry32 / pickK determinism, bar width id-derived |
| verdict.test.ts | 13 | idempotent resolve, podium payouts, citation rules, public-record earns no citation |
| filters-marker.test.ts | 12 | L3 filters, note sanitizer, comment-marker round-trip |
| serialize.test.ts | 11 | I2 โ truth never serialized, censored bars carry no text |
| dealer.test.ts | 10 | deterministic deal + pivot reservation |
| meter-time.test.ts | 8 | meter clamping, case-day / verdict clock |
| contradictions.test.ts | 6 | lit / newly-lit deltas |
| postComment.test.ts | 6 | consent-gated comment posting + app-account fallback chain |
| shared.test.ts | 5 | realtime channel naming, rank tiers, redis key schema |
| viewer.test.ts | 4 | logged-out โ loid โ anon fallback chain |
| demoSeed.test.ts | 3 | synthetic-bundle branches the 3 authored cases never happen to hit |
npm run type-check (tsc --noEmit) is clean; npm run build emits
dist/client/{splash,game}.html + dist/server/index.cjs.
npm run test:coverage enforces a 100% statements/branches/functions/lines
gate (vitest.config.ts), scoped to the pure/mockable business logic โ
src/shared/**, src/server/core/**, src/server/cases/**,
src/server/routes/**, and the single Redis-touching store.ts +
serialize.ts + keys.ts + viewer.ts + postComment.ts + redisLike.ts.
Excluded on purpose: src/client/** (React/DOM rendering โ needs a real
browser) and src/server/index.ts (process bootstrap โ calls
createServer(...).listen(...) at import time). 15 lines carry a narrow
/* v8 ignore */ with a one-line rationale, all for data-integrity guards
that are unreachable given the compiler's own validation (e.g. every
elimination-path fact id is checked against the case's fact set at compile
time) or a fixed, non-empty generated registry โ never a shortcut around a
real test.
| Layer | Tool | Where |
|---|---|---|
| Type safety | tsc --noEmit (strict) |
CI ยท npm run type-check |
| Lint | ESLint 9 + typescript-eslint (flat config) | CI ยท npm run lint |
| Unit tests + coverage | Vitest โ 269 tests / 17 files, 100% on the pure/mockable core (real-surface Redis stub) | CI ยท npm run test:coverage |
| Content quality | Solvability + demo linter (MC 1000/1000) | CI ยท npm run lint:cases |
| Build | Vite โ dist/client + dist/server |
CI ยท npm run build |
| SAST | CodeQL (javascript-typescript) |
.github/workflows/codeql.yml |
| Supply chain | Dependabot (npm + actions, weekly) | .github/dependabot.yml |
.github/workflows/ci.yml runs the five real gates on Node 20:
npm ci โ lint โ type-check โ test:coverage โ lint:cases โ build.
Deliberately N/A โ Lighthouse CI and Playwright-against-localhost. This is a
Devvit Web app: the client is bundled into dist/client/*.html and served by the
Reddit runtime inside a webview, so there is no localhost server to point a
headless browser or Lighthouse at. Interactive verification is devvit playtest
(human-run โ see the First-playtest checklist below).
Deterministic per-viewer censorship means a determined group can screenshot their shards into a Discord and pool them off-platform. We do not claim to prevent this, and it isn't a leak of the ground truth (the truth section never leaves the server โ I2): a screenshot only reveals shards those accounts were already dealt. In practice that behaviour is the game โ the evidence board just formalizes and rewards (citation economy) the un-redaction the crowd would do anyway. The design leans into sharing rather than pretending to stop it.
You (the human) run these โ an agent cannot run devvit playtest.
- Create the test subreddit from your aged main account on day one. New
hackathon subreddits are being auto-banned ("Rule #2") by Reddit safety
automation, including a re-ban immediately after you install a Devvit app.
Front-load the round-trip: create r/the_redacted_game_dev, add a normal pinned post
before installing anything, and keep the unban-request forum thread handy โ
staff unban manually when you post
username + subreddit. Expect a second ban at first app install. npm run login(devvit login), thennpm run dev(devvit playtest r/the_redacted_game_dev).- Probe the onComment payload before trusting the parser. The
onCommentCreatetrigger exists (docs-cachetriggers.md) but the exactCommentV2field shape is only confirmed at runtime. The handler (src/server/routes/internal.ts) is a defensive adapter that reads every field optionally and no-ops on anything unrecognised โ file one card, watch the logged payload, confirmcomment.body/comment.author, then tighten. - From the mod menu run REDACTED: Seed Demo Case โ restores Case #17 at ~61% with the pivot pool full and the first contradiction lit (idempotent). Open the post, peel your shard, file it, watch the meter tick and a suspect bar shift.
- Verify the three crons (
/internal/cron/{drop,drip,verdict}) โ all idempotent โ and, if you want extra live cases during judging, REDACTED: Launch Bonus Case. npm run check:submissionto re-run the readiness gate before you record video.
Run npm run check:submission before submitting. The demo-post URL stays a
placeholder (and the gate stays red) until the post is live.
The remaining
[ ]items need no app approval โ do them now. The Reddit review only unlocks the public App Directory listing + icon; judging happens on your (public) test sub, which is entirely in your control.
- App listing: https://developers.reddit.com/apps/the-redacted-game
- Devpost project: https://devpost.com/software/redacted-0jze1y
- Demo video: https://www.youtube.com/watch?v=nWXwBE4XXTs
- Demo post: https://www.reddit.com/r/the_redacted_game_dev/s/f04ylDXAJr
-
npm run lintclean -
npm run type-checkclean -
npm testgreen (269/269),npm run test:coverageat 100% -
npm run buildsucceeds - Published to the App Directory (
devvit publish, in review) - 60-second demo video recorded & published (link above)
- Public repo + Devpost project page linked
-
r/the_redacted_game_devset to Public, demo post seeded (Seed Demo Case) and verified againstDEMO.md - Demo-post URL filled above + Devpost form submitted
npm run lint # eslint .
npm run type-check # tsc --noEmit
npm test # vitest run (269 tests)
npm run test:coverage # vitest run --coverage (100% on shared/core/routes/store)
npm run lint:cases # solvability linter over cases/*.yaml
npm run compile:cases # emit cases/compiled/*.bundle.json + src/server/cases/registry.ts
npm run build # vite build โ dist/client + dist/server
npm run dev # devvit playtest (you run this)
npm run check:submission
Automatic semantic versioning via semantic-release:
every push to main parses Conventional Commits
(fix: โ patch, feat: โ minor, BREAKING CHANGE: โ major) and, when warranted,
bumps package.json, updates CHANGELOG.md, tags the commit, and publishes a
GitHub Release with generated notes (.github/workflows/release.yml). No manual
version bumps, no npm registry publish (private app).
MIT ยฉ 2026 Edy Cu. Built for Reddit's Games with a Hook hackathon.