2025
Noir Detective
A proof of concept: a murder mystery that generates its story at runtime with a local LLM.
Noir Detective is a proof of concept. I built it as a native desktop murder mystery: you interrogate suspects, collect clues, and accuse a killer. I do not write the cases by hand — a local model generates each one when you start a game.
That is the project: not “I called an API”, but making a local LLM produce a fair, solvable game. Early versions were atmospheric and broken. The only path to the killer needed a clue that never appeared. Testimony sat behind a lock you could never open. Three suspects, three alibis, none of them useful. The player dead-ended and felt cheated.
I split generation into three stages against Ollama (gemma4:e4b), each with a strict JSON schema.
Stage 1 is small: title, a detective monologue, the victim. It almost always comes back valid.
Stage 2 creates exactly three suspects and names one killer.
Stage 3 is the contract. I do not ask the model to “make the clues connect.” I tell it the mechanic:
Suspect 1 must offer an initial question that reveals “clue1”. That clue must unlock a new question on Suspect 2, which reveals “clue2”, and so on until “clue4” proves the killer.
The player still has room to wander. The graph has a path I can check.

The Go backend is the part I trust more than the model. JSON mode is a hint — I still strip markdown fences before json.Unmarshal. Dialogue maps try exact ids, then fuzzy names, because the model drifts. Each stage reports progress so a slow call still looks alive. The whole run retries once; after that I load a hand-written fallback. A third attempt costs more than a case I wrote myself.
cleanJSON := strings.TrimSpace(ollamaResp.Response)
if strings.HasPrefix(cleanJSON, "```json") {
cleanJSON = strings.TrimPrefix(cleanJSON, "```json")
cleanJSON = strings.TrimSuffix(cleanJSON, "```")
}
What this shows, if you are here from a CV: I can put an unreliable model behind a game loop and still ship something playable. The prompts are specifications. Every response goes through sanitisation, schema checks, and a way out.
| Piece | What I used |
|---|---|
| Runtime | Ollama (gemma4:e4b) |
| Generation | Three-stage prompt chaining |
| Structured output | JSON mode + fence stripping |
| Reliability | Validation + two-attempt retry |
| Fallback | Hand-authored case |
