How I built Crime case notebook

Kyle is the mercenary character in my life RPG. His case notebook connects witness statements, physical clues, and investigation targets so players can follow how a case develops.

I split the implementation into case generation, quest progress, and presentation.

QuestManager owns the progress data. Each bounty has a record containing collected statements, timestamps, inspected clues, and whether the final evidence has been recovered. The notebook reads that record rather than keeping its own copy of quest progress.

The evidence graph is a GDScript RefCounted class that turns those records into node and edge dictionaries. Cards represent statements, evidence, locations, and conclusions. Connections distinguish facts, testimony, and inference. Only discovered information appears, and the final target card is revealed once the required conditions are met.

The graph is a view of the investigation. Clicking a card doesn’t solve the case or change its progress.

For the UI, I used a custom Control with dynamically created Buttons. Column widths and row heights are calculated from the buttons’ minimum sizes. The Control’s _draw() method draws the connecting lines, with dashed lines for testimony and inference. Selecting a card emits a signal with its details and highlights its connections.

Procedural cases use a separate generator and an authored JSON pool. It first filters valid template-and-route combinations by dungeon progress, currently capped at floor three. It then selects an item, identifying feature, witness, route, appearance, and reward from the allowed options.

These aren’t stories written by an LLM during play. They’re combinations of authored content with constraints. Witness statements reuse the same item description as the case, and the witness cannot be the client.

The generator seeds RandomNumberGenerator with a hash of the pool version, world seed, and case sequence number. With the same inputs and pool, generation is repeatable. More importantly, the generated case itself is saved. Loading an existing case restores that definition instead of rolling a new one.

The investigation follows a controlled sequence: accept the case, speak to the relevant NPCs, inspect the clues, recover evidence from the designated dungeon target, and report to the client.

Clue placement also involves the 3D world. The code starts from authored anchors and offsets, finds a free navigation cell, and casts a physics ray to locate the ground before instantiating the clue model. Inspecting a clue records its timestamp and hides the world object, while keeping the notebook entry.

The target only spawns after the required testimony and clues are collected. Defeating it reports a case-specific event to the quest system. Reporting back uses the existing quest reward flow rather than letting the notebook award money.

I’ve written headless tests that generate cases across 100 seeds and check repeatability, distinct IDs, matching item descriptions, witness constraints, floor limits, reward bounds, and save/load restoration. Those checks cover logical consistency; they don’t establish that every generated case feels different enough to play.

The current generator varies a structured investigation. It doesn’t invent arbitrary motives, suspects, or branching plots. That gives me a manageable foundation for adding more authored variations.

5 Likes

I love the idea. Mixing one of the most popular game genres (RPG) with mystery. Great Idea but you know doing this something like this requires you to have great lore and structure. Keep that in mind because if the lore doesn’t make sense the mystery part of the game wont be focus as much.

lore = storyline