ProjectVoice AI · 2026
The Noir Game
A voice-only detective game on LiveKit, Deepgram, NVIDIA NIM and ElevenLabs, where the whole interface is a conversation and there is nothing to click.
- Python
- Next.js
- LiveKit
- Deepgram
- NVIDIA NIM
- ElevenLabs
# voice only, no text box and no menuDetective You were at the pier at eleven. Who saw you?Suspect Nobody. That is the trouble with an alibi.Detective Then tell me about the car.Suspect What car? I take the streetcar, same as you.Detective The one with your prints on the door handle.Suspect (long pause)Suspect Fine. I moved it. I did not put him in it.Voice AI · Project
The Noir Game, or The Interrogation Room, is a voice-only detective story. You are the detective, the suspect is across the table, and you have to talk your way to a confession before they lawyer up. I built it because every voice agent I had worked on was an assistant. I wanted to hear one that had a motive instead of a task.
A character is a harder target than an assistant. An assistant may pause, apologize and ask a clarifying question; a suspect who does that stops being a suspect. The conversation has to keep moving, hold a secret, and change temperature when the player finds a weak point. That puts the load on turn-taking and on voice, not on the wording of an answer.
Lies with triggers
The case is authored, not generated. livekit-agents/case_content.py holds Victor Crane, the Halbrook file and a set of planted lies. Each lie carries four things: the truth he knows, the cover story he tells, the tells a detective might notice, and the specific trigger that forces it to crack. suspect.py folds those into the system prompt alongside four states. Calm is small talk. Defensive is the detective circling something true. Cracking is a contradiction caught. Breaking is the dam going. A confession requires the breaking state, at least three distinct lies caught, and sustained final pressure. Accuse him wildly instead and he asks for his lawyer, which is the other ending.
Three processes
The runtime splits into three processes so each concern stays legible. backend/main.py is a FastAPI server with two endpoints. /api/case returns the file the player reads first, and /api/token mints a LiveKit JWT. That token carries a RoomAgentDispatch for the agent named suspect, so joining the room is what summons him. livekit-agents/agent.py is the worker. Deepgram nova-3 transcribes, Kimi K2.5 answers over the OpenAI-compatible NVIDIA NIM endpoint, and ElevenLabs eleven_flash_v2_5 speaks. Silero VAD and the multilingual turn detector are pulled locally by python agent.py download-files, so end of turn is decided next to the audio. A Next.js 16 frontend presents the room.
The signal protocol
Every model turn has to be one JSON object, and signal_protocol.py is that contract. The fields are speech, state, stress_level, contradiction_caught, contradiction_topic, ending and internal_note. The agent splits the object. Only speech reaches text to speech, and the chat history is rewritten to that same text, so the model does not start imitating its own JSON wrapper. Everything else becomes LiveKit participant attributes. web/lib/signals.ts reads them to color the transcript, tag a caught contradiction and raise the ending screen. Each state also maps to an ElevenLabs voice profile. Calm sits at stability 0.75 and style 0.20, and breaking drops to 0.15 and 0.85, so the voice loosens as he does.
Decisions and costs
- Put the game state in the spoken turn rather than in a tool call. One object per turn keeps the line and the state consistent by construction. The price is a model that must never wrap its answer in prose, so
parse_turnfalls back to treating the whole response as speech. - Author the lies instead of generating a mystery. A hand-written crack trigger makes the game winnable and repeatable. It also means one case, and replay value ends once the player knows the trigger.
- Shift the voice with the state. Calling
update_optionsmid-session is the cheapest way to make pressure audible. It sits inside a try block because not every plugin version exposes it, which is the decision I would revisit.
Running it locally
Copy .env.example to .env and fill in LIVEKIT_*, DEEPGRAM_API_KEY, ELEVENLABS_* and NVIDIA_NIM_API_KEY. The download runs once. The last three commands each want their own terminal.
cd livekit-agents && python agent.py download-files
cd backend && uvicorn main:app --reload --port 8000
cd livekit-agents && python agent.py dev
cd web && npm run dev
Open http://localhost:3000, read the case file, and start talking. A crack tag appears the moment contradiction_caught arrives on the attributes, and the ending screen appears when ending is set.
Limits and license
One case ships today, and there is no persistence, no score history and no deployment target beyond the three local processes. CORS on the backend is pinned to http://localhost:3000. Kimi is asked for JSON on every turn at temperature 0.85, so the tolerant parser earns its place. Every provider is an external paid service, though NVIDIA NIM is free for prototyping. The source is MIT licensed, and LiveKit, Deepgram, NVIDIA NIM and ElevenLabs remain under their own terms.
Next projectOperations CLI