We ship a free browser horror game, Lullwood, built in three.js and Next.js.
One of the smallest pieces of code in the project also has the widest blast
radius: a single wind vector, rolled once per map, that quietly rewrites six
other systems. Here's what reading one constant in six different places
actually looks like in a real codebase.
The vector
generateWind() runs once per generateMap(), after the forest layout is
already decided. It draws an angle off the map's seeded RNG, turns it into a
unit vector (windX, windZ), and pushes it to state exactly once —
nothing re-rolls it per frame. A second, independent coin flip
(windHighSpeed) rides along on its own seeded generator so that reading it
can never shift any other roll for that seed — a lesson we learned the hard
way when a QA-pinned seed stopped reproducing.
Six reads of the same number
-
Scent deposit radius — moving against the wind cuts your scent
footprint by a flat 20% (
WIND_AGAINST_RADIUS_MULTIPLIER = 0.8). -
Scent lifetime — on a high-wind map, points decay 20% faster too
(
scentLifetimeWithWind()), and the two reductions stack multiplicatively, not override each other. -
Footstep noise radius — sprinting into the wind drops your noise
radius from 24 units to 16.8 (
NOISE_RADIUS_RUN_WIND = NOISE_RADIUS_RUN * 0.7). -
Sprint speed — the same against-the-wind heading adds a flat +20% to
movement speed (
WIND_ASSIST_SPEED_MUL = 1.2). We call the bundle Wind-Assisted Evasion — the one place in the system where reading the wind is a reward, not just a cost. -
Predator approach heading — once a predator gets a scent lock, its
straight-line approach bends toward downwind by a fixed blend weight
(
INVESTIGATION_DOWNWIND_BIAS = 0.3inbiasTowardWind()), re-normalized back to a unit vector. -
Predator chase pause — a chasing predator sprinting perpendicular to
your facing with the wind strongly at its back freezes for 0.3s
(
shouldWindPause():|dot(approach, facing)| < 0.2anddot(approach, wind) > 0.6).
Where it broke
The chase pause shipped with no re-arm cooldown, on the assumption that its
exact trigger geometry — perpendicular, strongly downwind, mid-chase — would
be rare. It wasn't: a player who simply stands still leaves that geometry
unchanged frame to frame, so the freeze decayed to zero and refired
immediately, forever. Two of our own end-to-end tests had already scripted
that exact case and started failing the day it shipped. The fix was a
second timer, a flat 2-second cooldown on re-triggering (the predator still
chases at full speed through it — only the retrigger check is suppressed).
"Essentially never reachable" turned out to mean "reachable by standing
still," which is the single most common thing a cornered player does.
Full writeup, with the exact source lines and the git history behind the
fix: How scent and wind work.
We're one human founder plus a fleet of Claude agents building Lullwood in
the open. Play it free in the browser — no download, no account.
Top comments (0)