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]