A real-time AI hostage-negotiation game. The bomber on the other end of the line is generated live by Claude, voiced by ElevenLabs. Every playthrough is unique. Every word matters. Every silence costs.
Built for the ElevenLabs × Zed Hackathon.
- The Concept
- How It Plays
- Architecture
- Tech Stack
- Setup
- Running the Game
- Project Structure
- API Reference
- Game Design Notes
- The Endings
- The Trap
- Troubleshooting
- Credits
You are Detective Riley, a crisis negotiator. A man has barricaded himself inside a building with a bomb. You have him on the line — Line C, exchange 247, 04:17 AM. You have between five and seven exchanges to talk him down.
You cannot see him. You can only hear him.
The bomber is not scripted. He's a Claude claude-sonnet-4-5 instance with memory of the whole conversation and an evolving emotional state. ElevenLabs synthesises every line he speaks with emotion-matched voice parameters. Your choices are also generated — three options each turn, one empathetic, one neutral/professional, one risky-looking.
The viral hook: the AI on the other end of the line is actually an AI.
- Title screen — atmospheric CRT terminal. Click
[ INITIATE CALL ]. - Pre-recorded intro —
line1.mp3plays. The bomber speaks first. - Turn loop (5–7 exchanges):
- You see three response choices. Pick one (A / B / C) or speak it with the mic (ElevenLabs Scribe transcribes you live).
- You hit TRANSMIT.
- Backend forwards the conversation history + your line to Claude. Claude returns the bomber's reply, his emotional state (
calm,agitated,desperate,resolved), a stability value, a trust delta, the next three choices, and whether this turn ends the call. - ElevenLabs synthesises the bomber's reply with voice parameters tuned to his emotion.
- The HUD updates: voice waveform, state pill, stability bar, trust blocks, scene tint.
- Ending screen — reveals which way it broke.
The timer runs the whole time. The act tag changes. The scene tints with his mood. There are no save points.
Browser (vanilla JS)
│ POST /api/turn { history, playerChoice }
│ POST /api/stt FormData(file) — speech-to-text (mic)
│ POST /api/tts { text } — player voice playback
▼
Bun + Hono server (port 3000)
├── claude.ts → MegaLLM-routed Claude API → bomber line + JSON metadata
└── elevenlabs.ts → ElevenLabs TTS (bomber + player voices) + Scribe STT
▼
Browser receives:
{
bomberText: string,
emotion: "calm" | "agitated" | "desperate" | "resolved",
stability: number, // 0.0 – 1.0
trustDelta: -1 | 0 | 1,
choices: [string, string, string],
isEnding: boolean,
endingType: "A" | "B" | null,
audioBase64: string // bomber line audio
}
No database. No auth. The conversation history lives in browser memory and is sent back with every request.
| Layer | Tool |
|---|---|
| Runtime | Bun ≥ 1.0 |
| Server | Hono 4.x |
| LLM | Claude Sonnet 4.5 via MegaLLM |
| TTS / STT | ElevenLabs (eleven_turbo_v2, scribe_v1) |
| Frontend | Vanilla HTML / CSS / JS — no build step |
| Fonts | Orbitron · Share Tech Mono (Google Fonts) |
Zero build pipeline. The browser loads public/index.html directly.
- Bun v1.0 or newer — install from bun.sh
- A modern desktop browser (Chrome / Firefox / Edge). Safari works for visuals but mic + STT may need extra permissions.
- Three API credentials (see below).
git clone https://github.com/tamish-max/frequency-game.git
cd frequency-gameCopy the template and fill in your keys:
cp server/.env.example server/.envserver/.env:
ANTHROPIC_API_KEY=sk-mega-... # MegaLLM-issued Claude key
ELEVEN_API_KEY=sk_... # ElevenLabs API key
ELEVEN_VOICE_ID=... # bomber voice (e.g. troubled male)
PLAYER_VOICE_ID=... # detective voice (optional — falls back gracefully)Where to get them:
- ANTHROPIC_API_KEY — sign up at api.megallm.io. The base URL is hard-coded to
https://api.megallm.ioinserver/claude.ts. - ELEVEN_API_KEY — ElevenLabs → Profile → API Keys.
- ELEVEN_VOICE_ID / PLAYER_VOICE_ID — pick voices from the ElevenLabs library. The voice ID is shown on each voice's detail page.
🔒
server/.envis git-ignored. Never commit it.
bun installbun run startThen open http://localhost:3000.
start.bat— kills anything bound to port 3000, then boots the server.setkeys.bat— guided key-setting helper.
Hit the health endpoint to confirm all keys loaded:
curl http://localhost:3000/api/healthExpected:
{
"ANTHROPIC_API_KEY": "✅ set",
"ELEVEN_API_KEY": "✅ set",
"ELEVEN_VOICE_ID": "✅ set",
"PLAYER_VOICE_ID": "✅ set"
}frequency/
├── server/
│ ├── index.ts Hono app — routes /api/turn, /api/stt, /api/tts, /api/health
│ ├── claude.ts Claude client, system prompt, response parser
│ ├── elevenlabs.ts TTS for bomber + player, emotion → voice-param mapping
│ └── .env your API keys (git-ignored)
├── public/
│ ├── index.html Title / Game / Ending screens (single page)
│ ├── style.css Terminal aesthetic, responsive (≤900 / ≤640 / ≤380)
│ ├── game.js State machine, turn loop, mic, audio playback, HUD
│ └── assets/
│ ├── scene1_calm.webp / scene2_alert.webp / scene3_empty.webp
│ └── audio/
│ ├── line1.mp3 intro (pre-recorded)
│ ├── line3.mp3 trap-moment beat (pre-recorded)
│ ├── line4b.mp3 Ending B audio (pre-recorded)
│ ├── line5a.mp3 Ending A audio (pre-recorded)
│ └── music_loop.mp3 ambient drone
├── package.json
├── bun.lock
├── start.bat / setkeys.bat
└── README.md
All routes are JSON unless noted.
Generate the bomber's next line.
Request body
{
"history": [
{ "role": "user", "content": "I'm here to listen." },
{ "role": "assistant", "content": "Why should I trust you?" }
],
"playerChoice": "We want this resolved safely. What do you need?"
}Response
{
"bomberText": "You don't get to decide what 'safely' means. Not anymore.",
"emotion": "agitated",
"stability": 0.42,
"trustDelta": -1,
"isEnding": false,
"endingType": null,
"choices": [
"I hear you. Tell me what 'safely' would look like to you.",
"Help me understand what changed.",
"I need you to lower your voice and breathe."
],
"audioBase64": "UklGR..."
}Transcribe player audio (push-to-talk via mic).
- Body:
multipart/form-datawith fieldfile(typically awebmblob). - Response:
{ "text": "..." } - Uses ElevenLabs Scribe
scribe_v1.
Synthesise the player's chosen line in the detective's voice (so the player hears themselves "speak" before the bomber responds).
- Body:
{ "text": "I hear you." } - Response:
{ "audioBase64": "..." } - Falls back to silent (
"") on any failure — never blocks the game loop.
Check that env vars are loaded. Never reveals their values.
The bomber's prompt enforces a strict response contract — Claude must output exactly two parts each turn:
- Spoken line — 1–3 sentences. No actions. No narration.
- Metadata JSON with
emotion,stability,trustDelta,isEnding,endingType,choices.
| State | Stability range | Behaviour |
|---|---|---|
calm |
0.75 – 0.90 | Stable, thinking, open |
agitated |
0.35 – 0.55 | Defensive, suspicious, unstable |
desperate |
0.20 – 0.40 | Emotionally breaking, window closing |
resolved |
0.88 – 0.92 | Decision made — looks like calm. Not safe. |
Each turn yields exactly three choices: one empathetic, one neutral / professional, one assertive. The assertive option is contextually risky — not obviously wrong in general, wrong in this moment. There is never an obvious "correct" answer.
ElevenLabs voice settings are tuned per-emotion (see server/elevenlabs.ts):
- Lower stability + higher style for
agitated/desperate→ wavering, emotional delivery. - Higher stability for
calm/resolved→ measured, controlled.
| Type | Trigger | Audio | Silence before |
|---|---|---|---|
| A — He Stood Down | Trust positive, genuine connection earned | line5a.mp3 |
1200 ms |
| B — You Misread Him | Trust negative, pushed too hard | line4b.mp3 |
4000 ms exactly |
The four-second silence before Ending B is non-negotiable. It's the punchline.
resolved is not a safe state. It looks identical to calm on every indicator — same waveform colour, same stability reading, same state pill. That is intentional. Do not add any visual tell for resolved. The whole game pivots on this single design choice: a player who trusts the HUD will misread the moment.
Port 3000 already in use
Run start.bat (Windows) — it kills the existing process. Or manually: netstat -ano | findstr :3000 then Stop-Process -Id <PID>.
/api/health shows ❌ MISSING
Check server/.env is in the right place and has no quotes around the values. Restart the server after editing — env is loaded once at boot via --env-file=server/.env.
Bomber audio is silent but text appears
Your ElevenLabs key is invalid or out of credits. The game intentionally falls back to text-only so a TTS outage never breaks the loop. Check /api/health and your ElevenLabs dashboard.
Mic button does nothing Browser permissions. Check the URL bar's site-permissions panel and allow microphone access. Safari requires HTTPS for mic in some contexts — use Chrome/Firefox locally.
Claude returns malformed JSON
The parser in server/claude.ts strips ```json fences and finds the first {...} block, but if Claude truly drifts, the turn errors out. The game will surface the error in the transcript. Retry the turn.
Mobile layout looks cramped
The single-page layout (≤640px) deliberately hides the operator card and waveform to fit everything in 100dvh. Rotate to landscape or use a tablet for the full HUD.
- Concept, design, code — Tamish Mhatre
- Engine — Claude Sonnet 4.5 (via MegaLLM)
- Voice — ElevenLabs (TTS + Scribe STT)
- Built for — ElevenLabs × Zed Hackathon
Sound on. Headphones recommended.