destroyulator/game/scripts/Backrooms.gd
Monster Robot Party bcd67906a4 LANE7: you can die in LEVEL 0, and how you died decides which death you get
NO HEALTH BAR. Nothing else in this game does damage, so hit points would be a
survival-horror UI bolted onto a demolition game. Instead: one meter, DREAD, fed by
every way this place gets at you — being stared at, something close behind you, each
loop, and your own coughing. It decays when nothing is happening to you, and every loop
makes it grip harder (DREAD_LOOP_GRIP), so the place gets better at this the longer you
stay.

At 100% you die, and WHICH source pushed it over decides the death, which is what keeps
the three memes legible at the one moment it matters:

  STARE  "YOU LOOKED TOO LONG / it was still. it was always still. that was never the
         safe part."                                              (Slenderman)
  REACH  "IT WAS ALREADY IN THE ROOM / you were making so much noise."
         Sprays. This is the gory one.                            (the xenomorph)
  LOOP   "YOU ARE STILL IN THE ROOM / you walked it eleven times. you will walk it
         again." Eleven loops and the room simply keeps you.       (Blair Witch)

Gore.gd is procedural — seeded blobs that swell on the lens and then bleed downward in
streaks, a wet burst at the camera (physical deaths only; being taken by the static
shouldn't spray), the camera going down to land on its side on the carpet, then the
card. The sequence is deliberately SLOW: a jumpscare is a spike, but a death should take
its time, because the seconds spent watching the lens settle are where it lands. R wakes
you up.

Player.set_frozen() so you can't stroll around during your own death.

Renamed Backrooms' VHS layer to `tape` — `dread` is the meter now, and it was clearer
anyway.

dev/probe_death.gd drives all three deaths and the revive.

Note: the "Parameter material is null" warning only appears under the headless dummy
renderer; windowed runs are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:44:26 +10:00

364 lines
11 KiB
GDScript

extends Node
class_name Backrooms
## LEVEL 0's horror layer: the entity, the loop, the totems, and the scares.
##
## THE LOOP is the Blair Witch bit and the most important part. Walk far enough in any
## direction and you come out of the opposite wall — into the same room, because the maze
## is generated from a fixed seed and every part of it looks like every other part. You
## get a lurch, a tear on the tape, and the growing certainty that you have been here.
##
## Crucially, a wrap is also when the entity gets to reposition. So "I've been here
## before" and "it's already here" arrive in the same second, which is a far better
## scare than either on its own.
##
## THE NOISE RULE ties the horror to the game: the entity hunts sound, and the objective
## is to break things. Playing well is what feeds it. Playing quietly means not playing.
##
## Godot 4.7 GDScript 2.0.
const TOTEM := "res://assets/store/totem.glb"
## DREAD is the death meter, and it is deliberately the ONLY new number.
##
## A health bar would be a foreign object in this game — nothing else does damage, and
## adding hit points just to have something to subtract from is how you end up with a
## survival-horror UI bolted to a demolition game. So instead: one meter, fed by every
## way this place gets at you, and at 100% you die.
##
## Which death you get depends on WHICH input pushed it over, so the three memes stay
## distinguishable in the moment it matters:
## STARE you looked too long, and the static took you. (Slenderman)
## REACH it got to you. (gory; the xenomorph)
## LOOP you walked the same room too many times. (Blair Witch)
enum Death { NONE, STARE, REACH, LOOP }
const DREAD_FROM_STARE := 0.155 ## per second at full stare pressure
const DREAD_FROM_NEAR := 0.085 ## per second when it's right behind you, unseen
const DREAD_FROM_LOOP := 0.11 ## per wrap
const DREAD_FROM_LUNGS := 0.030 ## per second while you're coughing your guts up
const DREAD_CALM := 0.055 ## per second when nothing is happening to you
## Every loop makes the place better at this. Twelve loops and it barely lets go.
const DREAD_LOOP_GRIP := 0.055
## How close to the outer wall triggers a wrap.
const WRAP_MARGIN := 1.4
## Seconds after a wrap before another can fire (stops thrashing on a corner).
const WRAP_COOLDOWN := 1.2
var active := false
var entity: Entity
var tape: Dread ## the found-footage layer
var rage: Rage
var dust: Dust
var loops := 0 ## how many times the room has come back around
var caught := 0
var dread := 0.0 ## 0..1 — the death meter
var dying := false
var death_cause: int = Death.NONE
var gore: Gore
var _death_t := 0.0
var _death_from := Transform3D.IDENTITY
var _player: Node3D
var _cam: Camera3D
var _hud: Hud
var _host: Node3D
var _half := 19.0
var _wrap_cd := 0.0
var _totems: Array[Node3D] = []
var _next_whisper := 0.0
## Lines that fire on a wrap. Deliberately flat — the room is doing the work.
const WRAP_LINES := [
"…you've been here.",
"this is the same room.",
"you turned left. you have always turned left.",
"the carpet has the same stain.",
"you are further in than you were.",
"it was behind you a moment ago.",
]
func setup(player: Node3D, cam: Camera3D, hud: Hud, r: Rage, d: Dust,
host: Node3D) -> void:
_player = player
_cam = cam
_hud = hud
rage = r
dust = d
_host = host
tape = Dread.new()
tape.setup()
host.add_child(tape)
gore = Gore.new()
gore.setup()
host.add_child(gore)
entity = Entity.new()
host.add_child(entity)
entity.setup(player, cam, _half)
entity.caught_you.connect(_on_caught)
## Called when a level finishes building. `half` is the site's half-extent.
func enter(half: float) -> void:
_half = half
active = true
loops = 0
caught = 0
_wrap_cd = 0.0
entity._bounds = half
entity.wake()
dread = 0.0
dying = false
death_cause = Death.NONE
gore.clear()
tape.set_active(true)
_scatter_totems()
if _hud != null:
_hud.toast("there is no exit. there is no one else here.")
func leave() -> void:
active = false
entity.sleep()
tape.set_active(false)
for t in _totems:
if is_instance_valid(t):
t.queue_free()
_totems.clear()
## Every destroyed object and every puff of powder is a dinner bell.
func on_noise(at: Vector3, loudness: float) -> void:
if active:
entity.hear(at, loudness)
# ---------------------------------------------------------------- totems
## Hung in doorways you have definitely already walked through.
func _scatter_totems() -> void:
for t in _totems:
if is_instance_valid(t):
t.queue_free()
_totems.clear()
if not ResourceLoader.exists(TOTEM):
return
var packed := load(TOTEM) as PackedScene
for i in 7:
var n: Node3D = packed.instantiate()
_host.add_child(n)
n.position = Vector3(randf_range(-_half + 3.0, _half - 3.0), 1.30,
randf_range(-_half + 3.0, _half - 3.0))
n.rotation.y = randf_range(0.0, TAU)
_totems.append(n)
# ---------------------------------------------------------------- per-frame
func _process(dt: float) -> void:
if not active or _player == null:
return
if dying:
_tick_death(dt)
return
_wrap_cd = maxf(0.0, _wrap_cd - dt)
_check_wrap()
_tick_dread(dt)
# how near it is while UNSEEN — the vignette breathing is the only tell you get
var near := 0.0
if entity.is_awake() and not entity.seen_now:
var d := entity.global_position.distance_to(_player.global_position)
near = clampf(1.0 - d / 16.0, 0.0, 1.0)
tape.drive(dt, entity.static_level, near)
gore.set_blood(clampf((dread - 0.72) * 2.2, 0.0, 0.55))
if dread > 0.72:
gore.arm()
# it whispers when it's close and behind you
_next_whisper -= dt
if near > 0.55 and _next_whisper <= 0.0:
_next_whisper = randf_range(5.0, 11.0)
if _hud != null:
_hud.toast("something is breathing")
if rage != null:
rage.provoke(0.03, "something is breathing")
## Everything this place does to you lands in one number. Whichever source pushed it
## over the line decides how you die — that's what keeps three deaths readable instead of
## one generic fail.
func _tick_dread(dt: float) -> void:
var src := Death.NONE
var gain := 0.0
if entity.seen_now:
gain += DREAD_FROM_STARE * (0.4 + entity.static_level)
src = Death.STARE
var d := entity.global_position.distance_to(_player.global_position)
if d < 7.0:
var g := DREAD_FROM_NEAR * (1.0 - d / 7.0)
if g > gain:
src = Death.REACH
gain += g
if dust != null and dust.inhaled > 0.6:
gain += DREAD_FROM_LUNGS * dust.inhaled
if gain <= 0.0:
dread = maxf(0.0, dread - DREAD_CALM * dt)
return
# the longer you've been going round, the harder it grips
dread = clampf(dread + gain * dt * (1.0 + loops * DREAD_LOOP_GRIP), 0.0, 1.0)
if dread >= 1.0:
_die(src if src != Death.NONE else Death.STARE)
# ---------------------------------------------------------------- dying
const EPITAPHS := {
Death.STARE: ["YOU LOOKED TOO LONG",
"it was still. it was always still. that was never the safe part."],
Death.REACH: ["IT WAS ALREADY IN THE ROOM",
"you were making so much noise."],
Death.LOOP: ["YOU ARE STILL IN THE ROOM",
"you walked it eleven times. you will walk it again."],
}
func _die(cause: int) -> void:
if dying:
return
dying = true
death_cause = cause
dread = 1.0
_death_t = 0.0
gore.arm()
entity.state = Entity.S.LUNGE
if _cam != null:
_death_from = _cam.transform
# only the physical deaths spray. Being taken by the static shouldn't.
if cause != Death.STARE:
Gore.splatter(_host, _cam.global_position - _cam.global_transform.basis.z * 0.4,
-_cam.global_transform.basis.z)
if _player != null and _player.has_method("set_frozen"):
_player.set_frozen(true)
## Slow on purpose. A jumpscare is a spike; a death should take its time, because the
## seconds spent watching the camera settle onto the carpet are where it lands.
func _tick_death(dt: float) -> void:
_death_t += dt
var t := _death_t
# blood floods the glass over the first second and a half
gore.set_blood(clampf(t / 1.5, 0.0, 1.0))
if _cam != null:
if t < 0.35:
# the hit
_cam.transform = _death_from
_cam.rotation.x = _death_from.basis.get_euler().x + randf_range(-0.30, 0.30)
_cam.rotation.z = randf_range(-0.35, 0.35)
else:
# and then you go down, and the camera lands on its side on the carpet
var k := clampf((t - 0.35) / 2.4, 0.0, 1.0)
k = k * k * (3.0 - 2.0 * k)
_cam.position.y = lerpf(0.0, -1.35, k)
_cam.rotation.z = lerpf(_cam.rotation.z, 1.35, clampf(dt * 2.0, 0.0, 1.0))
_cam.rotation.x = lerpf(_cam.rotation.x, -0.55, clampf(dt * 2.0, 0.0, 1.0))
if t > 2.6:
gore.set_fade(clampf((t - 2.6) / 1.4, 0.0, 1.0))
if t > 4.0 and not _card_shown:
_card_shown = true
var e: Array = EPITAPHS.get(death_cause, EPITAPHS[Death.STARE])
gore.show_card(String(e[0]), String(e[1]))
var _card_shown := false
## Cross the boundary and you come out the far side — into a room that is identical,
## because every room here is identical. The entity repositions on the same frame.
func _check_wrap() -> void:
if _wrap_cd > 0.0:
return
var p := _player.global_position
var lim := _half - WRAP_MARGIN
var wrapped := false
if absf(p.x) > lim:
p.x = -signf(p.x) * (lim - 1.2)
wrapped = true
if absf(p.z) > lim:
p.z = -signf(p.z) * (lim - 1.2)
wrapped = true
if not wrapped:
return
_wrap_cd = WRAP_COOLDOWN
loops += 1
_player.global_position = p
if _player is CharacterBody3D:
(_player as CharacterBody3D).velocity = Vector3.ZERO
tape.flash()
# the good bit: it is somewhere new, and you have no idea where
entity._reposition(9.0)
if entity.state == Entity.S.STALKING and loops >= 2:
entity.state = Entity.S.HUNTING # it learns
dread = clampf(dread + DREAD_FROM_LOOP, 0.0, 1.0)
if loops >= 11 or dread >= 1.0:
_die(Death.LOOP) # the room keeps you
return
if _hud != null:
_hud.toast(WRAP_LINES[loops % WRAP_LINES.size()])
if rage != null:
rage.provoke(0.035, "the same room")
func _on_caught() -> void:
caught += 1
tape.flash()
if _cam != null:
_cam.rotation.x += randf_range(-0.22, 0.22)
_cam.rotation.z += randf_range(-0.2, 0.2)
# it doesn't kill you. It costs you: a lungful, a spike of rage, and the certainty
# that it can do that whenever it likes.
if dust != null:
dust.inhaled = clampf(dust.inhaled + 0.30, 0.0, 1.0)
if rage != null:
rage.provoke(0.16, "IT TOUCHED YOU")
dread = clampf(dread + 0.34, 0.0, 1.0)
if dread >= 1.0:
_die(Death.REACH)
return
if _hud != null:
_hud.toast("IT TOUCHED YOU")
## R after a death. Rebuild is Main's job; this just clears the horror state.
func revive() -> void:
dying = false
_card_shown = false
death_cause = Death.NONE
dread = 0.0
loops = 0
caught = 0
gore.clear()
if _cam != null:
_cam.position = Vector3.ZERO
_cam.rotation = Vector3.ZERO
if _player != null and _player.has_method("set_frozen"):
_player.set_frozen(false)
entity.wake()
func hud_line() -> String:
if not active:
return ""
if dying:
return ""
var st := int(round(entity.static_level * 100.0))
var warn := ""
if entity.state == Entity.S.HUNTING:
warn = " · it is looking for you"
elif entity.state == Entity.S.LUNGE:
warn = " · RUN"
var label := "steady"
if dread > 0.85:
label = "YOU ARE GOING TO DIE HERE"
elif dread > 0.62:
label = "get out get out get out"
elif dread > 0.34:
label = "you are not alone"
return "loops %d · touched %d · signal %d%% · DREAD %d%% (%s)%s" % [
loops, caught, st, int(round(dread * 100.0)), label, warn]