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


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

  1. Window → Package Manager → Universal RP → Install.
  2. Right-click in Project → Create → Rendering → URP Asset (with 2D Renderer). It creates two assets.
  3. Edit → Project Settings → Graphics → drag the URP Asset into Scriptable Render Pipeline Settings.
  4. Edit → Project Settings → Quality → set the same asset for each quality level.
  5. 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

  1. GameObject → Light → Light 2D → Global Light 2D.
  2. 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:

The flashlight rig

  1. Right-click Player → Create Empty, name it Flashlight.
  2. 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
  1. Add a Polygon Collider 2D to the Flashlight object, Is Trigger ✓, shaped roughly like the light cone.
  2. Add a Hitbox component to it:
Field Value
Target Team Enemy
Damage 1
Repeat Delay 0.5
Knockback Force 2
  1. Set the Flashlight object'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:

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

  1. Empty GameObject Lamp + a sprite.
  2. Child with a Light 2D (Point, Outer Radius 3, warm colour).
  3. On the parent: Circle Collider 2D, Is Trigger ✓, radius ~3 (matching the light).
  4. Layer → SafeLight.
  5. 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
  1. 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 PuzzleSwitch to SafeLightZone.SetOn(false) and a room the player thought was safe stops being safe.


7. Build the ghosts

  1. Empty GameObject Ghost, Tag → Enemy, sprite (semi-transparent reads well).
  2. Rigidbody2D — Gravity Scale 0, Freeze Rotation Z ✓.
  3. Capsule Collider 2D.
  4. Health — Max Health 4, Invulnerability 0, Destroy On Death ✓.
  5. FloatingEnemy — Path Wander (radius 4) or Waypoints, Speed 1.5, Bob Height 0.25.
  6. EnemyChase — Movement Free, Detect Radius 6, Lose Radius 9, Chase Speed 2.5, Require Line Of Sight ✓ with Ground Layer set.
  7. 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
  1. Children: Hurtbox (Team Enemy, layer Hurtbox) and BodyHitbox (Target Player, Damage 1, Repeat Delay 1.5, layer Hitbox).

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.

LightSensitiveEnemy exists 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

  1. Empty GameObject Stairs_F1_Up at the top of floor 1, with a trigger Box Collider 2D.
  2. Add an Interactable — Prompt Text Go up, One Shot ✗.
  3. 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
  1. Wire the Interactable's On Interact → FloorTransition.Use.
  2. Build the matching Stairs_F2_Down pointing 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

  1. Empty GameObject ClueJournal + the component, Persist Across Scenes ✓.
  2. 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.
  1. 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.
  2. Wire a UI Button (or a Journal input action) to ClueJournal.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:

  1. BatteryBar — a Slider, top-left. Delete the handle, Interactable ✗, Min 0 / Max 1. Colour it amber.
  2. 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.
  3. Health — hearts or a small bar.
  4. ClueCount — a TMP text, Clues: 0 / 9.
  5. Empty child HUD with 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: OnMeterChanged sends a float and Image.color wants 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.

  1. The scene is dark except your flashlight.
  2. The beam follows the mouse.
  3. F toggles it off and on.
  4. The battery bar drains while it's on — about 25 seconds.
  5. Walking with it off doesn't recharge. Standing still does.
  6. The dread meter fills in the dark after the grace period, and the vignette darkens.
  7. Standing in the lamp's radius clears the dread even with the flashlight off.
  8. A full dread meter costs health every 2 seconds.
  9. A ghost in the beam flees and fades.
  10. A ghost out of the beam chases you.
  11. Pressing E on a clue adds it to the journal and the counter rises.
  12. 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

  1. The building. Five floors is your content. Start with two done properly.
  2. Dialogue using Guide 10 §5's panel — a ghost that talks is scarier than one that only chases.
  3. Clue-gated conversations — Require Flags = clue_photo on a dialogue line.
  4. FuelPickup batteries scattered as a resource.
  5. Locked floors — FloorTransition with Require Flags, plus a Door needing a key.
  6. EndingSelector — endings based on how many clues you found. ClueJournal.OnAllCluesFound is a natural best-ending trigger.
  7. 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

16. Checklist