Guide 16 — Detective Liz: Haunted Case
Mena Rofail
What you're building
A 2D narrative horror game. Liz investigates a five-story abandoned building, using her flashlight to fight ghosts while gathering clues.
Jax's ideas in class, all built into this guide: a flashlight battery, a recharge that requires standing still and leaves you vulnerable, and bad effects from staying in the dark too long. Those three together make a genuine dilemma, and it's the best mechanical idea anyone pitched.
Yours is the most technically demanding build in the class: a render pipeline change, two separate UI systems, and five floors in one scene.
Time: four hours or so. Section 3 alone can eat an hour if URP fights you.
1. Before you start
- Finish Guide 0. You need the 2D (URP) template — if you made a plain 2D project, section 3 tells you how to convert.
- Read Guide B §B1 (player) and §B2 (managers).
- Read Guide 10 §5 — you need the dialogue panel. Build it from that guide's recipe.
- Two new input actions:
InteractandFlashlight.
2. Scripts you'll use
Light (Tier 6) — your signature system
Flashlight2D · DarknessMeter · SafeLightZone · LightSensitiveEnemy
Narrative (Tier 6)
Interactable · InteractionPrompt · ClueJournal · DialogueData · DialogueRunner · DialogueTrigger · EndingSelector · FloorTransition
Enemies (Tier 2)
FloatingEnemy · EnemyChase · Hurtbox · Hitbox
Battery (Tier 3)
FuelSystem · FuelPickup · LadderClimber + Ladder
Core & flags
PlayerController2D · PlayerAnimatorBridge · Health · DamageOnContact · Checkpoint · RespawnManager · CameraTargetAssigner · SceneLoader · GameManager · AudioManager · HUDController · ChoiceFlagManager · Door
3. Set up 2D lighting — do this first
Without this, nothing else in your game makes sense. Your building has to actually be dark.
If you used the 2D (URP) template
You're mostly set. Skip to "Make it dark."
If you're on a plain 2D project
- Window → Package Manager → Universal RP → Install.
- Right-click in Project → Create → Rendering → URP Asset (with 2D Renderer). It creates two assets.
- Edit → Project Settings → Graphics → drag the URP Asset into Scriptable Render Pipeline Settings.
- Edit → Project Settings → Quality → set the same asset for each quality level.
- Existing sprites may go pink. Select them and Edit → Rendering → Materials → Convert Selected Built-in Materials to URP, or set their material to
Sprite-Lit-Default.
Converting a project mid-way is annoying. If your project isn't far along, making a fresh 2D (URP) project and re-importing is often faster.
Make it dark
- GameObject → Light → Light 2D → Global Light 2D.
- Set its Intensity to 0.15 and give it a cold blue-grey colour.
Your scene should now be nearly black. That's correct. Everything Liz sees from here comes from her flashlight or from lamps you place.
One thing to check
Your sprites need the Sprite-Lit-Default material to respond to lights. If something stays fully bright in the dark, that's why.
4. Build Liz
Guide B §B1, then:
| Component | Setting | Value |
|---|---|---|
Health | Max Health | 4 |
| Invulnerability Time | 1.2 | |
PlayerController2D | Move Speed | 6 — slower than an action game |
| Jump Force | 12 |
Add:
FuelSystem— Max Fuel 100, Start Fuel 100 (this is the battery)LadderClimber— Climb Speed 4- Child
Hurtbox— trigger collider, Team Player, layerHurtbox
The flashlight rig
- Right-click
Player→ Create Empty, name itFlashlight. - Inside it, add a Light 2D (GameObject → Light → Light 2D → Spot Light 2D):
| Setting | Value |
|---|---|
| Light Type | Spot (or Point with narrow angles) |
| Inner / Outer Angle | 25 / 40 |
| Outer Radius | 8 |
| Inner Radius | 1 |
| Intensity | 1.2 |
| Colour | warm off-white |
| Falloff Intensity | 0.6 |
- Add a Polygon Collider 2D to the
Flashlightobject, Is Trigger ✓, shaped roughly like the light cone. - Add a Hitbox component to it:
| Field | Value |
|---|---|
| Target Team | Enemy |
| Damage | 1 |
| Repeat Delay | 0.5 |
| Knockback Force | 2 |
- Set the
Flashlightobject's Layer →Hitbox.
The component
On Player, add Flashlight2D:
| Field | Value |
|---|---|
| Beam Transform | the Flashlight child |
| Beam Light | the Light 2D GameObject |
| Beam Hitbox | the Hitbox on the Flashlight |
| Aim | Mouse |
| Aim Smoothing | 12 |
| Rotation Offset | 0 |
| Battery | the FuelSystem on the player |
| Drain Per Second | 4 |
| Recharge Requires Standing Still | ✓ ← the design |
| Recharge Rate | 8 |
| Recharge Delay | 0.5 |
| Flicker Below | 0.2 |
| Start On | ✓ |
| Toggle Mode | ✓ |
Add the Flashlight input action (bind F).
Why these numbers
Drain 4/second means a full battery lasts 25 seconds of light. Recharge 8/second means a full refill takes about 12 seconds of standing still. That ratio is the game: light is expensive, refilling is slow and dangerous.
Tune the ratio, not the numbers individually. If it feels too tight, drop the drain to 3 before raising the recharge.
5. Add the dread meter
On Player, add DarknessMeter:
| Field | Value |
|---|---|
| Fill Rate | 0.15 — takes ~7 seconds of darkness to fill |
| Drain Rate | 0.5 — clears in 2 seconds of light |
| Grace Seconds | 1.5 |
| Flashlight Counts | ✓ |
| Flashlight | the Flashlight2D component |
| Safe Light Layers | SafeLight |
| Check Radius | 0.3 |
| Damage Per Interval | 1 |
| Damage Interval | 2 |
The dilemma, assembled
Read these two rules together:
- The flashlight only recharges while standing still
- Standing in the dark fills the dread meter, and a full meter hurts you
So when the battery runs out, you must stop — and stopping in the dark starts a clock. The way out is to find a SafeLightZone and recharge there, which turns lamps into genuine sanctuaries.
That's your game. Everything else is content.
Grace Seconds 1.5 means walking through a shadow is free. Without it the meter would fill constantly and the mechanic would be noise rather than tension.
6. Build safe lights
- Empty GameObject
Lamp+ a sprite. - Child with a Light 2D (Point, Outer Radius 3, warm colour).
- On the parent: Circle Collider 2D, Is Trigger ✓, radius ~3 (matching the light).
- Layer →
SafeLight. - Add SafeLightZone:
| Field | Value |
|---|---|
| Light Visual | the Light 2D child |
| Start On | ✓ |
| Flicker | ✓ for a failing bulb |
| Flicker Chance | 0.05 |
| Flicker Duration | 0.08 |
- Save as a prefab.
Place these so that each floor has one or two — enough to recharge, far enough apart that getting between them matters.
A lamp you can switch off is a great story beat. Wire a ghost's death or a
PuzzleSwitchtoSafeLightZone.SetOn(false)and a room the player thought was safe stops being safe.
7. Build the ghosts
- Empty GameObject
Ghost, Tag →Enemy, sprite (semi-transparent reads well). - Rigidbody2D — Gravity Scale 0, Freeze Rotation Z ✓.
- Capsule Collider 2D.
- Health — Max Health 4, Invulnerability 0, Destroy On Death ✓.
- FloatingEnemy — Path Wander (radius 4) or Waypoints, Speed 1.5, Bob Height 0.25.
- EnemyChase — Movement Free, Detect Radius 6, Lose Radius 9, Chase Speed 2.5, Require Line Of Sight ✓ with Ground Layer set.
- LightSensitiveEnemy:
| Field | Value |
|---|---|
| Reaction | Flee (see below) |
| Flashlight | leave empty — it finds the player's |
| Cone Angle | 35 — roughly match your Light 2D's outer angle |
| Range | 8 — match Outer Radius |
| Requires Line Of Sight | ✓ |
| Wall Layers | Ground |
| Flee Speed | 4 |
| Fade While Lit | ✓ |
| Lit Alpha | 0.35 |
| Resist Time | 0.2 |
- Children:
Hurtbox(Team Enemy, layerHurtbox) andBodyHitbox(Target Player, Damage 1, Repeat Delay 1.5, layerHitbox).
Pick a reaction per ghost type
| Reaction | Plays like |
|---|---|
| Flee | runs from the beam. Light is a tool for controlling space |
| Freeze | stops dead and is vulnerable. Light is a stun |
| Weaken | keeps coming but takes damage from the beam Hitbox |
| Banish | burns away on its own timer |
Use more than one. A fleeing ghost teaches the player that light pushes; a weakening ghost teaches that light kills. Together they make the flashlight feel like a real instrument rather than a gun.
LightSensitiveEnemyexists separately from the beam's Hitbox on purpose. The interesting part isn't damage — it's that the ghost behaves differently in light.
8. Build the five floors in one scene
Don't make five scenes. Five scenes means five copies of Liz, the HUD, the flashlight state, the battery and the clue journal — and every floor change would reset them.
Instead: lay all five floors out in one scene, stacked vertically with plenty of empty space between them (say 30 units), and teleport between them.
The stairs
- Empty GameObject
Stairs_F1_Upat the top of floor 1, with a trigger Box Collider 2D. - Add an Interactable — Prompt Text
Go up, One Shot ✗. - Add FloorTransition:
| Field | Value |
|---|---|
| Mode | OnInteract |
| Destination | the matching stairs object on floor 2 |
| Fade Time | 0.3 |
| Lock Time | 0.4 |
| Set Flag On Use | reached_floor_2 |
| Require Flags | (e.g. has_elevator_key for a gated floor) |
| Use Sfx | stairs |
- Wire the
Interactable's On Interact → FloorTransition.Use. - Build the matching
Stairs_F2_Downpointing back. Two-way stairs are two of these facing each other.
The camera per floor
Add a CinemachineConfiner2D to your camera and a CameraBounds polygon per floor. Drag the destination floor's bounds into the transition's New Confiner Shape and the camera's GameObject into Confiner Object — the camera re-frames on arrival.
(If Cinemachine's property names don't match, this fails silently — the transition still works, the bounds just don't update. Not a blocker.)
9. The clue journal
- Empty GameObject
ClueJournal+ the component, Persist Across Scenes ✓. - Fill in All Clues — one entry per discoverable fact:
| Id | Title | Text |
|---|---|---|
photo | A torn photograph | Two people. One face scratched out. |
ledger | The building ledger | Payments stop abruptly in March. |
key | A brass key | Too small for any door you've seen. |
- UI: a Canvas Panel with a Vertical Layout Group, a TMP prefab for entries, and a counter text. Drag them into the journal's Panel / Entry Container / Entry Prefab / Counter Text.
- Wire a UI Button (or a
Journalinput action) toClueJournal.TogglePanel.
Discovering a clue
On any Interactable in the world: On Interact → ClueJournal.Discover, then type the clue id in the string field.
Each discovery also sets the flag clue_<id>, so a dialogue line can use Require Flags = clue_photo and only appear once Liz has found the photograph. That's how an investigation game works — the conversation options you have depend on what you know.
10. The HUD
You need three readouts, and all three matter:
BatteryBar— a Slider, top-left. Delete the handle, Interactable ✗, Min 0 / Max 1. Colour it amber.DreadVignette— a full-screen Image with a dark, soft-edged border texture, Raycast Target ✗, alpha starting at 0. This is the dread meter: as it fills, the screen darkens at the edges.- Health — hearts or a small bar.
ClueCount— a TMP text,Clues: 0 / 9.- Empty child
HUDwith HUDController; assign the health display.
The vignette is worth the effort. A number going up doesn't feel like dread; the screen closing in does. If you don't have a vignette texture, a plain dark Image whose alpha rises works — less pretty, same effect.
11. Wire it up
| On this object | Event | Drag in | Choose |
|---|---|---|---|
Player → FuelSystem | On Fuel Changed | BatteryBar | Slider.value (Dynamic float) |
Player → DarknessMeter | On Meter Changed | DreadVignette | Image.color — see note |
Player → DarknessMeter | On Entered Darkness | AudioManager | PlaySFX (string) → heartbeat |
Player → DarknessMeter | On Found Light | AudioManager | PlaySFX (string) → relief |
Player → Flashlight2D | On Battery Empty | AudioManager | PlaySFX (string) → flashlight_empty |
Player → Health | On Died | RespawnManager | RespawnManager.OnPlayerDied |
Player → Health | On Health Changed | HUD | HUDController.UpdateHealth (Dynamic) |
RespawnManager | On Respawned | Player | DarknessMeter.ResetMeter |
RespawnManager | On Respawned | Player | FuelSystem.RefillFull |
Each Interactable | On Interact | ClueJournal | ClueJournal.Discover → type the id |
Stairs Interactable | On Interact | that FloorTransition | FloorTransition.Use |
Row 2 note:
OnMeterChangedsends a float andImage.colorwants a Color — they don't connect. Two options: use a Slider for dread instead of a vignette (works with no script), or ask your instructor for the two-line script that maps the float to the image's alpha. The script is worth it — the vignette is the better feedback.
Rows 8 and 9 matter. Respawning with a full dread meter and an empty battery means instant death on a loop.
12. First playtest
One dark room, one lamp, one ghost, one clue.
- The scene is dark except your flashlight.
- The beam follows the mouse.
- F toggles it off and on.
- The battery bar drains while it's on — about 25 seconds.
- Walking with it off doesn't recharge. Standing still does.
- The dread meter fills in the dark after the grace period, and the vignette darkens.
- Standing in the lamp's radius clears the dread even with the flashlight off.
- A full dread meter costs health every 2 seconds.
- A ghost in the beam flees and fades.
- A ghost out of the beam chases you.
- Pressing E on a clue adds it to the journal and the counter rises.
- Stairs move you to the next floor with a fade, and the camera reframes.
Steps 4–8 are the system. Test them as a sequence: turn the light on, walk until it dies, keep walking in the dark, feel the dread rise, then find the lamp.
13. Then add
- The building. Five floors is your content. Start with two done properly.
- Dialogue using Guide 10 §5's panel — a ghost that talks is scarier than one that only chases.
- Clue-gated conversations — Require Flags =
clue_photoon a dialogue line. FuelPickupbatteries scattered as a resource.- Locked floors —
FloorTransitionwith Require Flags, plus aDoorneeding a key. EndingSelector— endings based on how many clues you found.ClueJournal.OnAllCluesFoundis a natural best-ending trigger.- Sound design. Yours is a horror game; audio does half the work. Footsteps, a flashlight click, a hum from the lamps, silence in the dark.
14. Design notes for your game specifically
The three rules only work together. Battery drain alone is inconvenience. Standing-still recharge alone is a pause. Dread alone is a timer. Together they make a loop where the safe action (stop and recharge) is also the dangerous one (standing in the dark), and the only real solution is to plan your route around the lamps. Don't remove any of the three to make it easier — instead widen the numbers.
Lamps are the shape of your level. Once light is a resource, a map is really a map of safe places and the distances between them. Design the building that way: how far is the next lamp, and what's between you and it?
Use at least two ghost reactions. Fleeing ghosts make light a tool for pushing enemies out of a corridor. Weakening ghosts make light a weapon. A player who has both learns to read which ghost they're facing, and that's a skill rather than a reflex.
A clue journal turns exploring into progress. "4 / 9 clues" is the difference between wandering a dark building and investigating one. Show the count prominently.
Five floors in one scene is the right architecture and it's worth understanding why: your battery, dread, health and clue state all persist for free. A scene load would reset all of it, and you'd be writing save/load code just to walk upstairs.
Consider whether Liz can die at all. Four health with dread damage is fairly forgiving. If you want more horror, drop her to 2 — but keep checkpoints frequent, because in a game about slow exploration a long walk back is punishing in the wrong way.
15. Common problems
| What you see | What's wrong |
|---|---|
| Scene isn't dark | No Global Light 2D, or its intensity is 1 |
| Sprites are pink | Materials not converted to URP |
| Sprites stay bright in the dark | Their material isn't Sprite-Lit-Default |
| No light from the flashlight | Light 2D missing, or the Flashlight child is disabled at start |
| Beam points backwards when facing left | Expected quirk — Flashlight2D compensates. If not, check the parent's scale isn't negative on both X and the child |
| Battery never drains | Battery field not assigned on Flashlight2D |
| Battery bar doesn't move | Wired to static value rather than Dynamic float |
| Recharges while walking | Recharge Requires Standing Still unticked |
| Dread fills instantly | Grace Seconds is 0 |
| Dread never fills | Flashlight Counts ✓ and your light is on — turn it off to test |
| Lamps don't clear dread | Lamp not on the SafeLight layer, or Safe Light Layers not set on DarknessMeter |
| Ghosts ignore the light | Cone Angle / Range don't match your Light 2D, or the ghost is behind a wall with Line Of Sight ✓ |
| Ghosts flee through walls | Expected — they're ghosts. Add wall colliders if you don't want it |
| Death loop on respawn | ResetMeter and RefillFull not wired to OnRespawned |
| Stairs do nothing | Interactable.OnInteract → FloorTransition.Use not wired |
| Camera shows two floors | No confiner, or the bounds shape covers both |