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>
477 lines
16 KiB
GDScript
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]
|