← Back to Portfolio

Case Zero

A one-room murder mystery where the tutorial is the liar. Built in Unity. Brackey's Game Jam 2026.

Engine Unity
Language C#
Team 2 Engineers
Theme Trust No One
Status Shipped
Timeline Weeklong Game Jam
Game title shot

Case Zero puts you right at the center of a murder mystery, and the player investigates a single room crime scene; examining clues, questioning suspects, and filing a case report to determine what happened. The twist sits in the jam's theme: "Trust No One." The narrator guiding the player through the tutorial is the one lying, not the suspects. Six discoverable clues, three NPCs with reactive dialogue, and a case board that gates the ending behind the player's own deductions.

Interrogation
Interrogation
Clue
Clue Inspection

Building a complete investigation loop with a second engineer meant integrating with an existing architecture I didn't design. The GM class, the ClueData/ClueDefinition pipeline, and the CaseContentPresenter were already in place when I started building the case board. The challenge was reading through the codebase, understanding how clues moved from player interaction through GM's event broadcast to the UI, and building a new system that hooked into that pipeline cleanly.

Core Problem

The game needed two things working together: a clue discovery system that tracked what the player found, and a case board that let the player select which evidence to include in their report. The report gates the ending — submit the right clues, and you're cleared. Miss the critical one, and you're arrested. The architecture had to let clues flow from world interaction to journal UI to ending logic without any of those systems reaching into each other directly.

I built the CaseBoardManager to subscribe to the GM's existing OnClueCollected event rather than polling or querying clue state. When the player discovers a clue anywhere in the scene, GM broadcasts the full ClueData object. The case board receives it, populates the next available journal button with the clue's photo, and makes it selectable.

The interaction system uses a raycast from screen center — the same approach I use in my Unreal prototype ZoneClear but adapted for first person. On hit, the system checks the object's tag to determine whether it's an NPC or a Clue, and routes to the appropriate response.

public void OnNewClueFound(ClueData clue)
{
    cluesCollected.Add(clue);
    clueButtons[cluesCollected.Count - 1].gameObject.SetActive(true);
    clueButtons[cluesCollected.Count - 1].GetComponent().sprite = clue.Definition.cluePhoto;
    clueButtons[cluesCollected.Count - 1].onClick.AddListener(() => SelectClue(clue));
}

The case board manages two lists: cluesCollected (everything found) and cluesToSubmit (the player's report selections, capped at three). Selecting a clue on the left panel populates the report on the right. Clicking a report slot clears it. A RefreshReportSlots method rebuilds the right panel from the current list state on every change, which avoids index tracking bugs and listener stacking issues from manually managing individual slots.

void RefreshReportSlots()
{
    for (int i = 0; i < reportSlots.Count; i++)
    {
        reportSlots[i].onClick.RemoveAllListeners();
        if (i < cluesToSubmit.Count)
        {
            int index = i;
            reportSlots[i].gameObject.SetActive(true);
            reportSlots[i].GetComponentInChildren().text = cluesToSubmit[i].title;
            reportSlots[i].onClick.AddListener(() => DeselectClue(cluesToSubmit[index]));
        }
        else
        {
            reportSlots[i].GetComponentInChildren().text = "";
            reportSlots[i].gameObject.SetActive(false);
        }
    }
    submitButton.interactable = (cluesToSubmit.Count == 3);
}			

The ending gate checks whether the submitted report contains the security terminal footage — the one piece of evidence that clears the player's name. Without it, the report defaults to an arrest.

NPCs use a reactive dialogue system: each NPC has a relatedClueId, and their dialogue changes if the player has already discovered the relevant clue. This means investigation order matters — talking to a suspect before or after finding their connected evidence produces a different conversation.

caseboard shot

What worked, and what I'd do differently.

What Worked
  • Event-driven clue flow. Subscribing to OnClueCollected meant the case board was completely decoupled from the discovery system. No polling, no shared state, no modifications to GM required. Adding a new clue to the game would automatically work with the journal.
  • RefreshReportSlots pattern. Rebuilding all three report slots from list state on every change eliminated an entire category of bugs — stale listeners, index mismatches, slots referencing removed clues. One method, always consistent.
  • Tag-based interaction routing. A single raycast from screen center checks the hit object's tag to branch between NPC dialogue and clue inspection. Simple, readable, and easy to extend to new interactable types.
What I'd Change
  • Red herring objects. Every interactable in the current build is case-relevant. Adding false interactables — objects the player can inspect but that don't contribute to the case — would make the deduction feel earned rather than guaranteed. The player should have to think about what matters, not just collect everything.
  • Broader investigation area. The single-room scope was right for solid polishing, but the mystery format wants adjacent rooms, locked areas, and spaces that reward exploration. The architecture supports it — the raycast interaction and event-driven clue system don't care about scene size.