destroyulator/game/dev/probe_two.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

78 lines
2.4 KiB
GDScript

extends SceneTree
## The second entity's inverted rules, and that exactly one thing changes per loop.
## Godot --headless --path game --script dev/probe_two.gd
func _initialize() -> void:
var main: Node = (load("res://main.tscn") as PackedScene).instantiate()
get_root().add_child(main)
await process_frame
main._load_level("backrooms")
for i in 8:
await physics_frame
var br = main.get("_backrooms")
var player = main.get("_player")
var other = br.other
print("at entry: other awake = ", other.is_awake(), " (should be false)")
# --- loop until it turns up. WRAP_COOLDOWN is 1.2 s, so the probe has to wait it
# --- out or only the first wrap ever fires.
for lap in 6:
var before := _snapshot()
var was: int = br.loops
player.global_position = Vector3(0, 0, 18.6)
for i in 90:
await physics_frame
var after := _snapshot()
print("loop %d -> %d : %d prop(s) moved" % [was, br.loops, _diff(before, after)])
print("after %d loops: other awake = %s" % [br.loops, other.is_awake()])
# --- INVERTED RULE: inert when unwatched ---
player.global_position = Vector3.ZERO
player.rotation.y = 0.0 # facing -Z
other.global_position = Vector3(0, 0.14, 9) # behind us
other.state = Entity.S.STALKING
for i in 4:
await physics_frame
var p0 = other.global_position
for i in 45:
await physics_frame
print("unwatched : moved %.2f m seen=%s (should be ~0)" % [
p0.distance_to(other.global_position), other.seen_now])
# --- and it comes when you DO look ---
player.rotation.y = PI # now facing +Z, straight at it
for i in 4:
await physics_frame
p0 = other.global_position
for i in 45:
await physics_frame
print("watched : moved %.2f m seen=%s (should be large)" % [
p0.distance_to(other.global_position), other.seen_now])
# --- and it is deaf ---
other.noise_heat = 0.0
br.on_noise(Vector3(-15, 0, -15), 3.0)
print("after a noise: heat=%.2f (should be 0 — it does not care)" % other.noise_heat)
quit()
func _snapshot() -> Dictionary:
var d := {}
for n in get_nodes_in_group("smashable"):
if is_instance_valid(n) and n is Node3D:
d[n.get_instance_id()] = [(n as Node3D).global_position,
(n as Node3D).rotation.y]
return d
func _diff(a: Dictionary, b: Dictionary) -> int:
var n := 0
for k in b:
if not a.has(k):
continue
var pa: Array = a[k]
var pb: Array = b[k]
if (pa[0] as Vector3).distance_to(pb[0]) > 0.25 or absf(pa[1] - pb[1]) > 0.2:
n += 1
return n