From Chatbot Demo to Full-Stack Investigation Game

7 min read
Share:

Introduction

A “you are the game master” chat box is easy. It is also easy to cheat. The model invents a key, walks a lock, or forgets the room. That is improvisation with no referee.

The Last Broadcast is the other design: a short investigation you can finish. AI may turn English into a command. A deterministic engine is the only thing that changes the world.

Play: https://ai-powered-interactive-game-master.vercel.app
Code: https://github.com/harshit-ttn/Build-an-AI-Powered-Interactive-Game-Master

If you only have ten minutes: open the live app, finish a few rooms, then in the repo open applyAction and CommandInterpreter. That is the whole argument.

Why this is worth your time

If you are wiring a language model into a product, the useful question is not “did we call the API?” It is “what happens when the model is wrong, slow, or missing?”

This project is a small, concrete answer. Same pattern shows up in assistants that must not invent refunds, bookings, or permissions: parse language at the edge; keep the ledger in code.

Chatbot demo This game
Model owns the world Engine owns GameState
Hard to test Handlers and a full win-path test
Dies without a vendor key Keyword parser still plays
One prompt, one story Second case is JSON
No URL, or a toy server Same-origin deploy

The idea

Two jobs get mashed together:

  1. Parse intent — messy language → { intent, target, confidence }.
  2. Apply rules — can you enter, did you earn the clue, which ending fires.

Models are useful at (1) and unreliable at (2). We split them.

Intents are closed: MOVE, INSPECT, SEARCH, TALK, USE_ITEM, ACCUSE, DECIDE. No TELEPORT. No invented loot. Illegal action → INVALID_ACTION and a GM line, not a rewritten universe.

Fiction: a hijacked city signal — “Find the witness before the silence.” Loop: search → evidence → theory → decide at the tower (stop / expose / continue). Cold Harbor is another mystery on the same engine. Stories are JSON. Physics are code. Health, trust, score, hints exist so choices hurt.

  • The model talks. The engine keeps score.

Stack and packages

TypeScript monorepo:

  • @broadcast/web — React 19, Vite, Zustand
  • @broadcast/api — Fastify 5, Zod, CORS, 80 req/min
  • @broadcast/game — engine + scenarios

The UI never “finds” a card on its own. It sends an HTTP request. The API runs the engine. GET /health is liveness. Zod rejects garbage before handlers run.

Tests sit next to the rules: engine tests cover locks, the locker card, puzzle sequences, USE_ITEM, health on a bad accusation, Cold Harbor boot, and a full path to hidden_truth. API tests cover start, a persisted MOVE, and parsing Use access card.

Try this (five minutes)

On the live URL:

  1. You start in the Control Room. Chip or type: go to Security Office.
  2. Inspect the evidence locker → access card + a clue.
  3. Use the card (inventory click or use access card).
  4. Lab: inspect the terminal, open Evidence Board, solve the three-step cipher (7 › 4 › 9).
  5. Keep going: desk note, server rack puzzle, archive, pin/connect all five clues, submit the theory, tower, decide.

You will not finish the best ending in five minutes. You will see locks, inventory, board, and a log that is not free-form fanfic. If a typed line is vague, you get Did you mean…? — that is the interpreter, not the engine “being nice.”

Mysteries as data

Locations, NPCs, locks, clues, puzzles, theories, inspect effects, dialogue, and ending rules live in a pack. Adding a case should not mean rewriting applyAction.

Mysteries_Data

The server room wants access_card. The elevator wants coordinates from the cipher. Best ending needs the right clues, a validated theory, and enough health.

Rules as a handler registry

Handler_Registry

applyAction clones state, refuses a finished case, dispatches, then finish() writes journal + suggestions. MOVE checks exits and locks. USE_ITEM needs inventory. INSPECT runs inspectEffects. TALK picks lines by NPC, trust, clues. Wrong accusation and failed theory cost health; repeated puzzle fails raise difficulty. DECIDE only at the ending location.

Pin, connect, submit. Wrong theory is a scored miss. Puzzles unlock after inspect; failed attempts raise hintLevel and can show a fragment. Hints cost −50 and follow your stuck point (lock, object, puzzle, missing clues). Optional AI only rephrases that hint.

memories[] (discoveries, talks) go into interpretation as the last ten strings — context for language, not a second physics engine. Chapters follow beats, not room count.

AI proposes; it does not loot

Command_Interpreter

With a key: strict JSON schema (intent enum, target, confidence). Prompt: return a command, do not invent items. Input: location, inventory, clues, memories, trust. No key → keyword parser.

AI_Proposal

Chips skip confirm. Narration is after the fact, off by default. Scenario generate is admin + Zod + never auto-loaded.

When things go wrong (this is the useful part)

What happens What the system does
No OPENAI_API_KEY Parser still plays the full case
Model returns junk Catch → same parser
“look around” / low confidence Confirm UI, no silent mutate
Inspect a name not in the room INVALID_ACTION, stall line, failed-action count
Use an item you do not have Rejected
Walk into a locked exit Lock message from the scenario
Accuse the wrong person Score, trust, health
Submit a thin theory Fail, −health, try again
Decide without evidence Worse ending, still a real ending
Case already closed No more mutations
Serverless cold start In-memory session can vanish. Resume from localStorage only works while that process still holds the case. The planned fix is durable storage (Redis or Postgres) so a new isolate can reload the same GameState.

If you copy one idea into your own service: never let the model write the ledger. Let it propose a DTO your code already understands.

UI, HTTP, and environment

Log, chips, command bar, map, inventory → USE_ITEM, evidence graph, tower checklist, scenario picker. localStorage + GET /state. Analytics: x-admin-key; events like game_started, clue_discovered, ending_reached. Warm process only.

Typical path: POST /api/v1/game/start → /interpret or /action → evidence/puzzle/hint → DECIDE. GET /health.

.env: PORT, optional OPENAI_*, ADMIN_DASHBOARD_TOKEN. Do not commit secrets. Do not ship the toy admin default on a public URL.

Run it yourself

Run_Command

Vite proxies /api locally. A static production build does not, so the hosted app uses one origin for UI and API.

Production

Production_Run

ESM via dynamic import() after a CJS wrap. Game sessions stay in memory on serverless, which is why a cold start can drop a case. Compose includes Postgres, but it is not on the live path yet. Next upgrade is wiring Redis or Postgres for session (and analytics) persistence, then more JSON packs. Git auto-deploy is not connected.

Where to look in the repo

Question File
What is a case? packages/game/src/scenarios/*.json
Who mutates state? packages/game/src/engine/apply-action.ts
Who only guesses? apps/api/src/ai/command-interpreter.ts
Confirm gate apps/api/src/server.ts (/interpret)
Client confirm apps/web/src/store/useGame.ts
Deploy adapter api/index.ts
Proof it is completable packages/game/src/engine.test.ts

Conclusion

The point is not that we used a model. It is a playable investigation whose physics are code, whose cases are JSON, and whose language layer you can rip out.

If the vendor is down, the case still runs. If the guess is sloppy, we confirm or reject — we do not rewrite the locker. If we want another mystery, we add a file.

I would not call the deploy a finished product. Memory-backed sessions will bite you. Durable storage first. Unconstrained world-gen would put the model back in the referee’s chair; I would not start there.

Open the URL, play until the board has a clue. Then open the two files above. One owns truth. The other only proposes.

Play: https://ai-powered-interactive-game-master.vercel.app
Repo: https://github.com/harshit-ttn/Build-an-AI-Powered-Interactive-Game-Master

Let the model talk. Let the engine keep score.

Leave a Reply

Your email address will not be published. Required fields are marked *