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>
This commit is contained in:
Monster Robot Party 2026-07-31 20:07:44 +10:00
parent bcd67906a4
commit a806272a7f
12 changed files with 405 additions and 21 deletions

View File

@ -243,6 +243,20 @@ xenomorph sprays), LOOP (the room simply keeps you at eleven). `Gore.gd` does bl
the lens with drips, a wet burst, and the camera going down onto the carpet — slow on
purpose, because a jumpscare is a spike but a death should take its time.
Still open: a second entity that behaves differently, and the "did that chair move?" pass
on wrapping — the room is identical on every loop, so *changing exactly one thing* would
be very cheap and very mean.
**THE OTHER ONE** *(built)* — after three loops there are two, and they share a
silhouette. Its rules are the first one's inverted: it advances ONLY while watched, is
inert when you look away, and is completely deaf. Every instinct the hunter trains into
you is how the other one kills you. Tells are close-range only and all wrong the same
way: head thrown back at the ceiling, arms up, feet that never reach the carpet.
**ONE THING PER LOOP** *(built)*`Backrooms._disturb()`. Exactly one change per wrap,
never two, because that constraint IS the effect: nothing and a loop is just a teleport,
several and it reads as a new room. One means you're never sure whether you noticed or
imagined it. Escalates from moving a prop half a metre or killing a light bank, to
hanging a totem that wasn't there, to permanently revealing something nobody dusted, to
putting the hunter exactly where you were standing.
Still open in LEVEL 0: the two entities can't yet be told apart by anything except
getting close, which is correct but means a first-time player has no way to learn the
difference except dying. A tell at medium range — a sound only one of them makes, or one
that never appears in the same frame as the other — would give skill somewhere to live.

View File

@ -91,6 +91,34 @@ settles → spray again, coughing.
It can't be fought — weapons pass through. The outs are distance, silence, and not
looking, three things the rest of the game actively discourages.
**And after three loops there are two of them.** THE OTHER ONE shares the silhouette —
same height, same suit, same dark — so at range you cannot tell which you're looking at.
Its rules are the first one's rules **inverted**:
| | the hunter | the other one |
|---|---|---|
| while you look at it | frozen | **it comes** |
| while you don't | it closes | inert |
| noise | hunts it | **deaf** |
| correct play | keep it in view, stay quiet | **look away**; noise is irrelevant |
Every instinct the first one trains into you is the way the second one kills you. The
tells are close-range only, and all wrong in the same direction: its head is thrown back
so it's staring at the ceiling rather than at you, its arms are raised, and its feet
never reach the carpet.
### The room changes one thing
Every loop, **exactly one thing** is different. Never two — that constraint is the whole
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're never
sure whether you noticed something or imagined it, and since the room is otherwise
identical, when you *are* sure it's worse.
It escalates: early loops move or turn a prop, or kill a bank of lights. Later ones hang
a totem that wasn't there, permanently reveal an object nobody dusted, or put the entity
where you were standing a moment ago.
### DREAD, and dying
**No health bar.** Nothing else in this game does damage, so hit points would be a

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://oj65sy2sq2j7"
path="res://.godot/imported/entity2.glb-664185e05164cbf13bb141c7656803fa.scn"
[deps]
source_file="res://assets/store/entity2.glb"
dest_files=["res://.godot/imported/entity2.glb-664185e05164cbf13bb141c7656803fa.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

View File

@ -48,14 +48,17 @@ func _find_player(n: Node) -> Player:
return null
func _build_script() -> void:
# LEVEL 0: stand in front of it, let the dread max out, and die.
# The two of them. Same silhouette at range; the tells are close-range only — the
# other one's head is thrown back, its arms are up, and its feet don't reach.
_script = [
{"dur": 1.2, "move_to": null, "look_at": null, "act": "level:backrooms"},
{"dur": 0.3, "move_to": Vector3(0, 0, 3.0), "look_at": Vector3(0, 1.5, -3.0),
"act": "ent:0,-3"},
{"dur": 2.6, "move_to": null, "look_at": null, "act": ""},
{"dur": 0.4, "move_to": null, "look_at": null, "act": "kill"},
{"dur": 7.0, "move_to": null, "look_at": null, "act": ""},
{"dur": 0.3, "move_to": Vector3(0, 0, 4.0), "look_at": Vector3(0, 1.6, -4.0),
"act": "ent:0,-4"},
{"dur": 3.0, "move_to": null, "look_at": null, "act": ""},
{"dur": 0.3, "move_to": null, "look_at": Vector3(4.0, 1.6, -4.0), "act": "two:4,-4"},
{"dur": 3.4, "move_to": null, "look_at": null, "act": ""},
{"dur": 3.0, "move_to": Vector3(2.0, 0, 1.0), "look_at": Vector3(4.0, 1.7, -4.0),
"act": ""},
]
func _motion(verbose := false) -> float:
@ -163,6 +166,14 @@ func _do(act: String) -> void:
br.entity.global_position = Vector3(float(p[0]), 0.0, float(p[1]))
br.entity.state = Entity.S.STALKING
return
if act.begins_with("two:"):
var b4 = _main.get("_backrooms")
if b4 != null and b4.other != null:
var q := act.substr(4).split(",")
b4.other.wake()
b4.other.global_position = Vector3(float(q[0]), 0.14, float(q[1]))
b4.other.state = Entity.S.STALKING
return
if act == "kill":
var b3 = _main.get("_backrooms")
if b3 != null:

View File

@ -0,0 +1 @@
uid://b51u6svomjek2

77
game/dev/probe_two.gd Normal file
View File

@ -0,0 +1,77 @@
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

View File

@ -0,0 +1 @@
uid://dhifjxyhb351x

View File

@ -49,6 +49,10 @@ 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
@ -100,9 +104,16 @@ func setup(player: Node3D, cam: Camera3D, hud: Hud, r: Rage, d: Dust,
entity = Entity.new()
host.add_child(entity)
entity.setup(player, cam, _half)
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
@ -112,6 +123,7 @@ func enter(half: float) -> void:
_wrap_cd = 0.0
entity._bounds = half
entity.wake()
other.sleep()
dread = 0.0
dying = false
death_cause = Death.NONE
@ -124,6 +136,7 @@ func enter(half: float) -> void:
func leave() -> void:
active = false
entity.sleep()
other.sleep()
tape.set_active(false)
for t in _totems:
if is_instance_valid(t):
@ -133,7 +146,7 @@ func leave() -> void:
## 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)
entity.hear(at, loudness) # the other one is deaf; it does not care
# ---------------------------------------------------------------- totems
## Hung in doorways you have definitely already walked through.
@ -169,6 +182,11 @@ func _process(dt: float) -> void:
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))
@ -195,6 +213,8 @@ func _tick_dread(dt: float) -> void:
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:
@ -297,6 +317,7 @@ func _check_wrap() -> void:
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
@ -306,6 +327,97 @@ func _check_wrap() -> void:
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()
@ -352,6 +464,7 @@ func hud_line() -> String:
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"
@ -359,5 +472,5 @@ func hud_line() -> String:
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]
return "loops %d%s · touched %d · signal %d%% · DREAD %d%% (%s)%s" % [
loops, twos, caught, st, int(round(dread * 100.0)), label, warn]

View File

@ -25,9 +25,24 @@ class_name Entity
## Godot 4.7 GDScript 2.0.
const MODEL := "res://assets/store/entity.glb"
const MODEL_OTHER := "res://assets/store/entity2.glb"
enum S { DORMANT, STALKING, HUNTING, LUNGE }
## Two of them, and the second one's rules are the FIRST one's rules inverted.
##
## HUNTER freezes while you look at it, closes when you don't, and hunts sound.
## Correct play: keep it in view, stay quiet.
## MIRROR advances ONLY while you look at it, is inert when you don't, and is deaf.
## Correct play: look away. Noise is irrelevant.
##
## They share a silhouette on purpose. At thirty metres in a yellow room you cannot tell
## which one you're looking at, so every sighting is a question, and the answer you
## learned first is the one that kills you. The tells are close-range only: the MIRROR's
## head is thrown back, its arms are up, and its feet never reach the carpet.
enum Kind { HUNTER, MIRROR }
var kind: int = Kind.HUNTER
## How far it can be seen from; beyond this it simply isn't rendered.
const SEE_DIST := 34.0
## Inside this, a lunge begins.
@ -59,12 +74,15 @@ var _sting: AudioStreamPlayer
signal caught_you() ## the jumpscare fires; Backrooms decides the cost
func setup(player: Node3D, cam: Camera3D, half_extent: float) -> void:
func setup(player: Node3D, cam: Camera3D, half_extent: float,
which: int = Kind.HUNTER) -> void:
_player = player
_cam = cam
_bounds = half_extent
if ResourceLoader.exists(MODEL):
var packed := load(MODEL) as PackedScene
kind = which
var path := MODEL if kind == Kind.HUNTER else MODEL_OTHER
if ResourceLoader.exists(path):
var packed := load(path) as PackedScene
if packed != null:
_mesh = packed.instantiate()
add_child(_mesh)
@ -105,8 +123,8 @@ func is_awake() -> bool:
## Something made a noise. The xenomorph half of it walks toward the last loud thing.
func hear(at: Vector3, loudness: float) -> void:
if state == S.DORMANT:
return
if state == S.DORMANT or kind == Kind.MIRROR:
return # the other one does not care what you break
if loudness >= noise_heat * 0.6:
noise_at = at
noise_heat = clampf(noise_heat + loudness, 0.0, 3.0)
@ -180,8 +198,11 @@ func _physics_process(dt: float) -> void:
var want := atan2(-flat.x, -flat.z)
rotation.y = lerp_angle(rotation.y, want, clampf(dt * 6.0, 0.0, 1.0))
# --- STATIC: looking at it is what hurts you ---
if seen_now:
# --- STATIC ---
# Only the HUNTER's stare degrades the signal. The MIRROR doesn't stare at you at
# all — it's looking at the ceiling — so its threat is purely that it is closer than
# it was, and it makes no noise on the tape to warn you.
if seen_now and kind == Kind.HUNTER:
var closeness := clampf(1.0 - dist / SEE_DIST, 0.0, 1.0)
static_level = clampf(static_level + stare_static * (0.35 + closeness) * dt,
0.0, 1.0)
@ -192,6 +213,10 @@ func _physics_process(dt: float) -> void:
_catch()
return
if kind == Kind.MIRROR:
_tick_mirror(dt, dist)
return
match state:
S.STALKING:
_tick_stalk(dt, dist)
@ -224,6 +249,20 @@ func _tick_hunt(dt: float, dist: float) -> void:
elif dist > SEE_DIST * 0.8 and noise_heat < 0.3:
state = S.STALKING
## The inversion. It is inert until observed, and then it comes — fast, and in a
## straight line, and it does not stop while you are still looking. Every instinct the
## HUNTER trained into you (keep it in sight, it's safe while watched) is now the way
## you die.
func _tick_mirror(dt: float, dist: float) -> void:
if not seen_now:
return # unwatched, it is furniture
var speed := hunt_speed * 1.15
if dist < LUNGE_DIST * 1.6:
speed = lunge_speed
_step_toward(_player.global_position, speed * dt)
if dist < 1.5 and _cd <= 0.0:
_catch()
func _tick_lunge(dt: float, dist: float) -> void:
_step_toward(_player.global_position, lunge_speed * dt)
if dist < 1.5 and _cd <= 0.0:

View File

@ -198,7 +198,62 @@ def p_totem(M):
return parts
PROPS = {"entity": p_entity, "totem": p_totem}
def p_entity2(M):
"""THE OTHER ONE. Same height, same darkness, same suit — at thirty metres in a
yellow room you cannot tell it from the first one, and that is the entire point,
because its rules are inverted and guessing wrong kills you.
Up close there are tells, and they're all wrong in the same direction: the head is
tilted fully back so it is looking at the ceiling and not at you, the arms are raised
above it, and the feet hang Backrooms.gd floats it, so it never touches the carpet."""
parts = []
# legs hang straight and limp; no knee break, no weight on them
for s in (-1, 1):
hip = Vector((s * 0.075, 0.0, 1.16))
ankle = Vector((s * 0.080, 0.03, 0.10))
parts.append(assign(span(0.055, 0.036, hip, ankle), M["suit"]))
foot = box((0.085, 0.20, 0.038), loc=(s * 0.080, 0.02, 0.085),
rot=(math.radians(28), 0, 0))
parts.append(assign(bevel(foot, 0.012, 2), M["suit"]))
torso = box((0.36, 0.19, 0.80), loc=(0.0, 0.0, 1.56))
parts.append(assign(bevel(torso, 0.055, 3), M["suit"]))
hips = box((0.24, 0.16, 0.20), loc=(0.0, 0.0, 1.20))
parts.append(assign(bevel(hips, 0.045, 3), M["suit"]))
for s in (-1, 1):
lap = box((0.09, 0.03, 0.44), loc=(s * 0.078, -0.095, 1.68),
rot=(0, math.radians(s * 5), 0))
parts.append(assign(lap, M["shirt"]))
# arms UP, not down — reaching for the ceiling it is already staring at
for s in (-1, 1):
sh = Vector((s * 0.190, 0.0, 1.92))
elb = Vector((s * 0.290, 0.02, 2.32))
wr = Vector((s * 0.250, 0.01, 2.76))
parts.append(assign(span(0.054, 0.042, sh, elb), M["suit"]))
parts.append(assign(span(0.042, 0.032, elb, wr), M["suit"]))
for f in range(4):
a = wr + Vector((s * 0.006 * (f - 1.5), -0.008, 0.015))
b = a + Vector((s * 0.014 * (f - 1.5), -0.02, 0.19 + 0.02 * abs(f - 1.5)))
parts.append(assign(span(0.010, 0.006, a, b, verts=6), M["skin"]))
parts.append(assign(ball(0.035, loc=tuple(wr), segs=12, rings=8), M["skin"]))
# head thrown back: the throat is what faces you
neck = span(0.050, 0.058, Vector((0, 0, 1.94)), Vector((0, -0.09, 2.14)))
parts.append(assign(neck, M["skin"]))
skull = ball(0.112, loc=(0.0, -0.12, 2.22), segs=24, rings=16)
skull.scale = (0.84, 1.02, 1.34)
bpy.ops.object.transform_apply(scale=True)
parts.append(assign(skull, M["skin"]))
back = ball(0.090, loc=(0.0, -0.02, 2.30), segs=20, rings=14)
back.scale = (0.76, 0.90, 1.50)
bpy.ops.object.transform_apply(scale=True)
parts.append(assign(back, M["skin"]))
return parts
PROPS = {"entity": p_entity, "entity2": p_entity2, "totem": p_totem}
def materials():