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.

Generation progress with a live terminal log

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

End of a generated case