destroyulator/game/scripts/Backrooms.gd
Monster Robot Party a806272a7f LANE7: there are two of them, and the room changes one thing every loop
THE OTHER ONE (Entity.Kind.MIRROR)
After three loops a second one turns up, and it shares the first one's silhouette on
purpose — same height, same suit, same dark — so at range you cannot tell which you are
looking at. Its rules are the hunter's rules INVERTED:

                     hunter                 the other one
  while watched      frozen                 IT COMES
  while unwatched    it closes              inert
  noise              hunts it               deaf
  correct play       keep it in view        LOOK AWAY

Every instinct the first one trains into you is the way the second one kills you, and
that is the entire design. The tells are close-range only and all wrong in the same
direction: head thrown back so it stares at the ceiling instead of you, arms raised, and
feet that never reach the carpet (Backrooms floats it 14 cm).

It also makes no static and doesn't breathe, so the only warning the tape gives for it
is the edge closing in — even while you are looking straight at it.

ONE THING PER LOOP (Backrooms._disturb)
Exactly one change per wrap. Never two. That constraint IS the effect: change nothing
and a loop is just a teleport, change several and it reads as a new room, which is the
opposite of what this place is for. One change means you are never sure whether you
noticed something or imagined it — and since the room is otherwise identical, when you
ARE sure it is worse.

Escalating: early loops shift a prop half a metre, turn one, or kill a bank of lights.
Later ones hang a totem that wasn't there, permanently reveal an object nobody dusted,
or put the hunter exactly where you were standing a moment ago.

The shift is half a metre, not one — a metre is enough to shove a neighbouring prop and
read as TWO changes, and it's too obvious anyway. Half is "…was that there?"

dev/probe_two.gd verifies the inversion with numbers (unwatched 0.00 m, watched 4.39 m,
noise heat 0.00) and that the disturbance fires per loop. Note the probe has to wait out
WRAP_COOLDOWN between laps or only the first wrap ever fires.

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

477 lines
16 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 other: Entity ## THE OTHER ONE — inverted rules, see Entity.Kind
## Loops before the second one turns up. You get long enough to learn the first set of
## rules properly, and then they stop being true.
const OTHER_AFTER_LOOPS := 3
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.Kind.HUNTER)
entity.caught_you.connect(_on_caught)
other = Entity.new()
host.add_child(other)
other.setup(player, cam, _half, Entity.Kind.MIRROR)
other.caught_you.connect(_on_caught)
# it hangs — the feet never reach the carpet, which is the close-range tell
other.position.y = 0.14
## 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()
other.sleep()
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()
other.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) # the other one is deaf; it does not care
# ---------------------------------------------------------------- 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)
if other.is_awake():
# it makes no static and it doesn't breathe at you, so the ONLY warning the tape
# gives for the other one is the edge closing in — even while you're staring at it
var d2 := other.global_position.distance_to(_player.global_position)
near = maxf(near, clampf(1.0 - d2 / 13.0, 0.0, 1.0) * 0.8)
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 other.is_awake():
d = minf(d, other.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
_disturb()
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")
# ---------------------------------------------------------------- the disturbance
## EXACTLY ONE THING changes per loop. Never two.
##
## That constraint is the whole effect. Change nothing and the loop is just a teleport;
## change several things and it reads as a new room, which is the opposite of what this
## place is for. One change means you are never sure whether you noticed something or
## imagined it — and the room is otherwise pixel-identical, so when you DO notice, you
## are certain, and that's worse.
##
## It escalates: early loops move furniture, later ones start putting things where you
## were standing.
func _disturb() -> void:
var props: Array = []
for n in _host.get_tree().get_nodes_in_group("smashable"):
if is_instance_valid(n) and n is Node3D:
props.append(n)
var options: Array = ["shift", "spin", "dark"]
if loops >= 2:
options.append("totem")
if loops >= 3:
options.append("reveal")
if loops >= 5:
options.append("behind")
var pick: String = options[randi() % options.size()]
match pick:
"shift":
# something is a metre from where it was. Only a metre.
if props.is_empty():
return
var p: Node3D = props[randi() % props.size()]
# small on purpose: a metre is enough to shove a neighbour and read as two
# changes, and it's also too obvious. Half a metre is "…was that there?"
p.global_position += Vector3(randf_range(-0.55, 0.55), 0.0,
randf_range(-0.55, 0.55))
"spin":
if props.is_empty():
return
var q: Node3D = props[randi() % props.size()]
q.rotation.y += randf_range(1.2, 2.6)
"dark":
# a bank of lights simply isn't on any more
var lights: Array = []
_collect_lights(_host, lights)
if lights.is_empty():
return
lights.shuffle()
for i in mini(4, lights.size()):
(lights[i] as OmniLight3D).light_energy *= 0.25
"totem":
# one more of them than there was
if not ResourceLoader.exists(TOTEM):
return
var packed := load(TOTEM) as PackedScene
var n: Node3D = packed.instantiate()
_host.add_child(n)
var fwd := -_player.global_transform.basis.z
fwd.y = 0.0
n.position = _player.global_position + fwd.normalized() * randf_range(4.0, 8.0) \
+ Vector3(0, 1.30, 0)
n.rotation.y = randf_range(0.0, TAU)
_totems.append(n)
"reveal":
# one object stops being invisible. Permanently. Nobody dusted it.
if dust == null or props.is_empty():
return
var r: Node3D = props[randi() % props.size()]
if r is Smashable:
dust._coat(r as Smashable)
(r as Smashable).set_meta("dust_until", 0x7FFFFFFF)
"behind":
# it is standing where you were a moment ago
var back := _player.global_transform.basis.z
back.y = 0.0
entity.global_position = _player.global_position + back.normalized() * 3.2
entity.state = Entity.S.HUNTING
# and after enough of them, the other one is here too
if loops >= OTHER_AFTER_LOOPS and not other.is_awake():
other.wake()
other.position.y = 0.14
if _hud != null:
_hud.toast("there are two of them.")
func _collect_lights(n: Node, acc: Array) -> void:
if n is OmniLight3D:
acc.append(n)
for c in n.get_children():
_collect_lights(c, acc)
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 twos := " · TWO" if other.is_awake() else ""
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%s · touched %d · signal %d%% · DREAD %d%% (%s)%s" % [
loops, twos, caught, st, int(round(dread * 100.0)), label, warn]