DEV Community

petgem
petgem

Posted on Originally published at lullwoodgame.com

How Scent and Wind Work in Our Three.js Horror Game

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.3 in biasTowardWind()), 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.2 and dot(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)