All work

First-Person Horror Experience · 2026

Hollowfield

A slow-burn first-person horror piece set in a farmhouse that watches you back.

Hollowfield

Hollowfield

A first-person horror experience built with Three.js. No jump scares, no asset downloads, no frameworks. Just 4,000 lines of vanilla JS synthesizing dread from scratch.

Live: hollowfieldexperience.com

Stack: Three.js r183 · Vite · Vanilla JS (ES modules) · GLSL shaders · Web Audio API

Build: 70 modules, ~606KB minified


Origin Story

Hollowfield started life as a 3D portfolio gallery. The Three.js renderer, camera rig, and pointer-lock controls were already sitting there working. So instead of building a horror game from zero, I repurposed the scene: gallery walls became farmhouse rooms, artwork became horror props, ambient light became oil lamps. The original README still describes the gallery project, a fossil left over from the pivot.

The insight that made it worth doing: a walking simulator and a horror experience share about 80% of their engineering. Both need first-person controls, collision, spatial audio, and atmosphere. The other 20% (tension pacing, peripheral-vision tricks, environmental storytelling) is where the horror actually lives.


Design Philosophy

Slow-burn dread, not jump scares. The fear comes from accumulating wrongness: lights that flicker a little more each minute, a shadow at the edge of vision that’s gone when you turn, a rocking chair that’s moved since you last looked, a scarecrow that slowly comes to face you. Nothing ever lunges at you. The environment itself becomes hostile.

Zero external assets. Every texture is generated in code with Canvas 2D (wood grain, wallpaper, dirt, straw, ceiling stains). Every sound is synthesized with the Web Audio API (footsteps, wind, creaks, whispers, thuds). This was half creative constraint, half practical: no loading screens waiting on a CDN, no format compatibility headaches, and a single ~573KB bundle that works offline.

Respect the player’s attention. Notes only appear when you’re both close AND looking at them. Doors want you facing them. The shadow figure only shows up in peripheral vision. Every system rewards paying attention to the space instead of mashing buttons.


Architecture Decisions

The HorrorDirector Pattern

The most important decision in the whole build: one object controls all horror. HorrorDirector owns a single tension float that climbs from 0 to 1 over five minutes, and every frame it pushes that value into the subsystems: flicker intensity, ambient volume, desaturation, grain strength. No subsystem runs its own timer. Nothing escalates on its own.

That was a deliberate reaction against “Christmas tree” horror, where every system separately decides to get scarier and the result is noise. With one tension source, the piece has a real arc: the first minute feels safe, minute two introduces subtle wrongness, minute three makes you doubt what you’re seeing, and by minute four the environment is openly hostile.

The EventSystem sits on top of that with 13 one-shot events scheduled across the five minutes (thuds, whispers, shadows, objects moving). Events are area-gated, so a kitchen whisper only fires if you’re actually in the kitchen. Early events skip the gating since you start locked in the kitchen anyway. The gameplay loop also calls bumpTension() on door unlocks, which gives a hybrid curve: a steady time-based rise plus discrete jumps at progression milestones. Every playthrough gets a slightly different sequence of scares depending on how fast you explore, but the overall intensity curve stays the same.

Factory Functions Over Classes

Every module exports a createX() function that returns a plain object. No class, no this, no inheritance. In a project where every system is a singleton, that’s the right shape. Closures give you private state for free, and dependencies stay explicit: each factory receives exactly what it needs as arguments.

// Every module follows this pattern
export function createFlicker(lamps) {
  let pattern = 'subtle';
  return {
    setPattern(p) { pattern = p; },
    update(delta) { /* uses closure-captured lamps and pattern */ }
  };
}

World Layout on a Single Axis

The whole play space runs along negative-Z: kitchen (z=0 to -5), hallway (-5 to -9), living room (-9 to -14), transition passage (-14 to -15), stables (-15 to -24), cornfield (-24 to -40). Figuring out which room you’re in is just a chain of z comparisons. No spatial index, no raycasting. That one constraint simplified collision, fog transitions, audio surface switching, and event targeting all at once.

The trade-off is that the world has to be linear. Branching paths would need a real spatial system. But for this piece, a guided descent from domestic safety into the rural unknown, the linearity is the point.

Collision via Raycasting Against Wall Meshes

There’s no physics engine. Collision is 8 raycasts per frame from the player: 4 cardinal directions plus 4 diagonals. Every solid surface (walls, doors, stall dividers, invisible corn-maze barriers) goes into one flat wallMeshes[] array handed to the first-person controller.

It’s dead simple and it handles moving objects for free. Doors live in wallMeshes[], so when a door swings open the collision follows it automatically. The diagonal rays exist to stop corner-clipping, where axis-aligned checks alone let you slide through a wall intersection at 45 degrees.

The cornfield collision has a nice optimization. Instead of one invisible plane per maze-cell edge (100-plus planes), the system scans the grid and merges adjacent edges into runs, ending up with roughly 30 to 40 planes total.


Technical Challenges

Procedural Audio That Doesn’t Sound Procedural

This was the hardest creative problem. Synthesized audio wants to sound like “beep-boop,” obviously fake. Three things got it over the line:

  1. Band-pass filtered noise. Footsteps aren’t sine waves. They’re filtered noise with a fast exponential decay. Wood steps use 650/850Hz (sharp), dirt uses 350/500Hz (dull), straw uses 900/1100Hz (crisp). Two frequencies per surface (one per “foot”) keeps it from turning into a monotone.

  2. Convolution reverb. A generated impulse response (1.2s, stereo) runs through Web Audio’s ConvolverNode at a 20% wet mix. That one addition takes flat synthesized sounds and makes them feel like they’re happening in a physical room.

  3. LFO-modulated wind. The ambient wind is white noise through a bandpass filter, but the filter’s center frequency slowly drifts (0.15Hz) between 40 and 120Hz. That’s what gives you the rise and fall of real wind, with no samples involved.

The whisper was the fiddly one. It’s noise with a 25Hz amplitude modulation to fake a speech rhythm, high-passed at 2000Hz for breathiness. You can’t make out words, but the cadence reads as speech right at the edge of hearing.

Door Mechanics: Geometry, Pivot, Collision, and Dual Control

Doors look simple until you build one. Each door is a thin BoxGeometry translated so the hinge edge sits at local origin. swingDir sets which edge is the hinge (+1 = left, -1 = right) and the sign of the open angle, so rotating around Y pivots at the hinge on its own.

Then the requirements pile up. A door has to collide (it’s in wallMeshes[]), animate smoothly (lerp-based, with an isAnimating guard), respond to the player (E key, gated on proximity plus look direction), support a locked state that blocks both the player and the horror system, AND be drivable by the horror system for autonomous creaking. The answer was a clean little API: toggle() for the player, triggerOpen() and triggerClose() for horror events, all sharing one animation path. The locked property guards both toggle() and triggerOpen(), so auto-creak events skip locked doors with no special-casing.

The wall at a doorway has to be split into 2 or 3 segments (left of door, right of door, lintel above) so the collision gap lines up exactly with the visual gap. Get it wrong and you either walk through walls or bounce off invisible barriers standing in an open doorway.

Peripheral Vision Detection

The shadow figure should only be visible when you’re NOT looking at it, which is a surprisingly specific thing to ask a renderer for. The trick is the dot product between the camera’s forward vector and the direction to the figure:

  • dot > 0.7 (direct gaze) fade out fast, at 5x speed
  • 0 < dot < 0.5 (peripheral vision) fade in slowly to a 60% opacity cap
  • dot ≤ 0 (behind you) despawn

The 0.5 threshold is about 60 degrees off-center, roughly where human peripheral vision starts. The figure uses MeshBasicMaterial (unlit) so it renders as a flat black silhouette no matter what the scene lighting is doing, which is what sells the “wait, was that actually there?” effect.

The same dot-product check gets reused for the scarecrow (turns to track you when dot < 0.3), notes (readable when dot > 0.4), and door interaction (needs dot > 0.4).

Fog Transitions Without Shader Recompilation

Different areas want different fog: the farmhouse interior is near-black and close, the cornfield is a dark blue-black that reaches further out. The obvious approach, swapping scene.fog for a fresh THREE.Fog object, makes Three.js regenerate shader programs for every material in the scene. So FogSystem.js mutates the existing fog object’s properties in place instead. Same look, zero shader recompiles.

The InstancedMesh Transparency Trap

Early on, the corn stalks used transparent: true for the leaf cutout. That made Three.js sort every instance by distance to the camera every single frame, which torched performance at 300 to 500 instances. The fix was alphaTest: 0.3 on an opaque material, letting the GPU discard pixels instead of the CPU sorting geometry. One line, and it bought back 15-plus FPS on mobile.


Performance Budget

The target is 60 FPS on mid-range desktop and stable 30+ FPS on mobile. Key budget decisions:

Decision Rationale
No shadows on any light 5 oil lamps + flashlight + moonlight = 7 shadow maps. The dim atmosphere makes shadow quality imperceptible anyway.
Bloom disabled on mobile UnrealBloomPass is the costliest post-processing step. Mobile gets grain + desaturation only.
Pixel ratio capped at 2 (desktop) / 1.5 (mobile) 3x devices render 9x pixels. Capping at 1.5x is visually indistinguishable on small screens.
All geometry is flat planes and boxes No subdivisions, no curved surfaces. The scarecrow is a cylinder + sphere + box.
Pre-allocated vectors Zero new Vector3() calls inside any update() function. Every scratch vector is module-scoped.
Hand-designed maze, not procedural The 11x11 corn maze grid is a hardcoded 2D array. This allows deliberate pacing (dead ends, loops, clear path to center) and means the collision wall merging runs once at startup, not per-generation.

The UI Layer

All the UI is HTML and CSS overlays. No 3D text, no HUD textures. That keeps it resolution-independent and easy to style with plain CSS. The design language:

  • Space Mono for horror labels (monospaced reads as slightly wrong)
  • DM Sans for body text (the notes have to be legible)
  • A dark palette: near-black backgrounds, warm off-white text, a sickly amber for note titles, dark red for horror accents
  • CSS transitions for every state change; JS only toggles classes

The tutorial screen adapts to the platform. Desktop shows WASD, mouse, and keybind icons as inline SVGs; mobile shows the split-zone touch layout with animated gesture hints. Both auto-dismiss after 5 seconds.

Mobile uses a dynamic-origin joystick: the control ring appears wherever your thumb lands rather than sitting in a fixed spot. That sidesteps the “thumb drift” problem you get with fixed virtual joysticks.


Storytelling Through Environment

Hollowfield tells its story through 8 findable notes sitting on real props: the kitchen counter, wall shelves, a side table, the mantelpiece, a grandfather clock, a TV set. Each note is a small paper mesh (0.12 × 0.08m) placed in the scene, and the full text slides up in a panel when you’re within 2 meters and looking at it.

The notes build a fragmented narrative: journal entries, scrawled warnings, ordinary documents with something off underneath. They’re deliberately sparse and out of order, so thorough exploration is rewarded but never required. The horror works whether you read all of them or none.

The stables and cornfield have no notes at all, and that’s on purpose. The farmhouse notes set the domestic normal; the outdoor areas are meant to feel wordless and exposed.


The Gameplay Loop

The original build was pure free-roam: all doors open, tension on a timer, no objectives. The first gameplay pass added locked doors and visible keys. The overhaul turned it into something with real mechanical depth.

Environmental Puzzles

Keys don’t sit out in the open anymore. Each one takes a few steps to uncover:

  1. Kitchen drawer. A small drawer mesh on the counter. Press E to slide it open and the rusty key is inside. Simple, but it teaches the mechanic.
  2. Grandfather clock. Press E to wind it. The pendulum starts swinging, the face panel opens, and the brass key drops to the floor in front of the clock. A one-second animation.
  3. TV puzzle. Gated behind the story. You have to read note-08 first (the Veterinary Report about the stables), then come back to the TV. The screen flickers to static and the iron key appears on the shelf. It’s the only puzzle that requires a specific note, tying mechanical progress to the storytelling.

Each puzzle object runs a tiny state machine (closed → opening → open) with per-frame animation. Keys start with mesh.visible = false and a hiddenUntilPuzzle flag, then get revealed via keyItems.reveal(id, position) when their puzzle finishes.

The House Changes Behind You

When you step into a newly unlocked room, the rooms behind you change:

  • Enter hallway: kitchen shelf lamp dims, candle goes out, a “Fresh Scratches” note appears on the table
  • Enter living room: hallway lamp dims, a “Stopped Clock” note appears at the grandfather clock
  • Enter stables: the TV gives off a faint static glow, both living room lamps dim, a “Static” note appears at the TV

The mutations are one-shot, tracked by a mutated Set. Each one calls director.bumpTension(0.05) for a little extra escalation. These particular notes use noMesh: true so they show up as proximity text with no physical paper prop, which reinforces the feeling that something shifted while your back was turned.

The matchbox (found on a hallway shelf) ties into this: the kitchen candle goes out during the first mutation, and the matchbox relights it, revealing a hidden note carved into the table underneath.

Point of No Return

The first time you cross into the stables, the back door slams shut and locks for good. The flicker system drops into a “dying” pattern for 3 seconds, a thud and creak play, and tension bumps. No going back. That turns the stables from a hallway into a commitment: you’re heading for the cornfield now whether you’re ready or not.

Flashlight Battery Drain

The flashlight drains from 100% to 0% over about 2 minutes of on-time. A thin bar in the top-right shows the charge (green, then yellow, then red). At 0% the light shuts off and F does nothing until you recharge.

Five battery pickups are tucked in dark corners across the areas, each worth 20%. That creates a resource squeeze: you need the flashlight to navigate and read notes, but burn it carelessly and you’re stumbling through the cornfield blind.

The existing batteryFlicker() horror event still works. It temporarily overrides intensity during a flicker episode, then restores to whatever the current battery-scaled level is.

The Cornfield Pursuit

Something you never see follows you through the maze. The pursuer sits in a maze cell and moves one cell toward you every 4 seconds using greedy pathfinding. An 11x11 grid doesn’t need A*: just step to the adjacent corridor cell closest to the player, and never onto the player’s own cell.

It makes itself known through three channels:

  • Corn rustle every 3 to 6 seconds (high-pass filtered noise bursts, very quiet)
  • Shadow figure spawns at the pursuer’s position when it’s 3 to 8m away AND behind you (reusing the peripheral-vision shadow system)
  • Exit beacon flicker: the cornfield exit PointLight randomizes its intensity when the pursuer is within 7m

The pursuer never catches you because it can’t occupy your cell. The threat is atmospheric, not mechanical. Shadow sightings at intersections you just passed and rustling that keeps getting closer build a real sense of being followed, with no fail state behind it.

Three Endings

The original single win screen became three distinct endings:

Ending Trigger Tone
Escape Reach cornfield exit Relief. “YOU ESCAPED” → “HOLLOWFIELD” → PLAY AGAIN
Truth Reach exit with all 8 original notes read Revelation. “THE TRUTH” → story text about the Mercer family → “You escaped. But it remembers.”
Trapped Tension hits 1.0 before you reach the cornfield Dread. Slow 3s fade to black → “You never left.” → “HOLLOWFIELD” → TRY AGAIN

The Truth ending gives thorough readers actual closure. The Trapped ending punishes stalling: linger too long without making progress and the house wins. Once you’ve reached the cornfield you’re safe from Trapped, because the pursuit is providing all the pressure you need out there.

Advanced Inventory

Two items beyond keys:

  • Matchbox (consumable). Found on a hallway shelf. Carry it near the darkened kitchen candle and an “[E] Light Candle” prompt appears. Using it spends the matchbox, relights the candle, and reveals a hidden note carved into the table.
  • Photograph (inspectable). Found on the living room side table. Click it in the inventory to open a full-screen panel whose text changes across 4 viewings, each more unsettling than the last. It walks from a normal family portrait to a single figure alone in a cornfield.

Crosshair-Based Interaction

The interaction system uses a center-screen raycast (Raycaster.setFromCamera at NDC origin) instead of proximity-plus-facing checks. Every frame the ray tests against the interactable targets:

  • Doors use mesh raycasting (intersectObjects), since they’re large moving surfaces
  • Small items (keys, batteries, puzzle items) use sphere hit tests via ray.distanceSqToPoint() with a 0.2m tolerance: precise enough to make you aim, forgiving enough not to annoy you
  • Puzzle objects use 0.35m sphere tests at their interaction points

The closest hit along the ray wins, so priority sorts itself out by distance instead of a hardcoded type hierarchy. Whatever you’re hovering gets a shimmer: a pulsing emissive glow, warm amber on dark materials, an intensity boost on things that already glow. Materials shared across instances (door material, battery material) are cloned per mesh so the shimmer doesn’t bleed onto its neighbors.

Tension Escalation Per Unlock

Each door unlock calls director.bumpTension(0.15), which adds a permanent baseline to the time-based curve. Three doors means up to 0.45 of baseline, so by the time you reach the cornfield tension is near the top no matter how long you took. House mutations add another 0.05 per changed room, and the point of no return adds 0.1. It ties horror intensity to progress rather than patience.


The Module Split

At ~500 lines, main.js was already over the project’s 200-line limit and would have ballooned further with 7 new features bolted on. Splitting it was Phase 0 of the overhaul:

File Responsibility Lines
main.js Renderer, scene, camera, init, setAnimationLoop ~100
setup/GameSetup.js Build all systems, return context object ~180
setup/GameLoop.js Per-frame loop (controls, area, horror, win) ~150
setup/Interaction.js E key handler + proximity priority system ~160
setup/UISetup.js ESC menu, mute, pointer lock, mobile controls ~130

The context-object pattern (createGameSystems returns one flat object holding every ref) avoids both global state and prop-drilling. The game loop destructures what it needs; the interaction system takes the whole context for flexibility. Late-binding callbacks like director.setOnTrappedEnding() cover the cases where a callback depends on objects that get created after the director does.

Environmental Detail Pass

The original rooms had 3 to 13 props each, enough to navigate by but not enough to feel lived in. A dedicated detail pass added ~60 new props across all five areas, roughly tripling the visual density:

  • Kitchen (+16): wood-burning stove with pipe and cast-iron pan, sink basin, wall cabinets with handles, boarded window with diagonal cross-boards, jar cluster, spilled flour patch, bucket, wall clock, hanging pot rack with pots, cutting board, scattered cutlery
  • Hallway (+10): coat hooks with hanging coat (subtle sway animation), console table, framed picture, floor runner rug, cracked mirror with scratch lines, wall cross, cobweb, broken ceiling light fixture, peeling wallpaper patch
  • Living room (+14): bookshelf with 8 colored book spines, fireplace surround, floor rug, armchair, picture frames, tattered curtains (sway animation), coffee table with newspaper stack, candlestick, sofa, scattered playing cards, toppled floor lamp
  • Stables (+10): saddle on sawhorse, hanging lantern (sway animation), feed bags, pitchfork, chain on wall, cobweb, horse blanket on rail, wooden crate, rusted nails
  • Cornfield (+5): 2 additional scarecrows, fence posts with cross-rail at entrance, weathered sign, 3 pumpkins
  • Outdoor (+6): 35 stars at sky level, dead tree with branches, porch steps, front fence with posts and rails

The pass also fixed Props.js blowing past the 200-line limit by splitting it into a props/ subdirectory: SharedMaterials.js (22 shared PBR materials) plus one file per area. The old Props.js shrank to a 47-line orchestrator. All the new geometry is boxes, planes, cylinders, and circles, no subdivisions, staying true to the project’s flat-geometry rule.

Three new Math.sin() animations (coat sway, curtain sway, lantern sway) use module-scoped counters, so there are no render-loop allocations. Mobile builds skip cobwebs, playing cards, flour patches, and stars (~50 fewer draw calls). Eight new colliders (stove, sink, console table, bookshelf, fireplace, armchair, coffee table, sofa) were folded into the existing collision array.

What’s Next

  • Sound design polish. The corn rustle and puzzle sounds could be richer
  • Mobile puzzle UX. Tap-to-interact alternatives for touch devices

Lessons Learned

  1. One authority over escalation. The HorrorDirector pattern (one tension source, many consumers) was the highest-leverage decision in the project. Without it the horror ends up either random noise or perfectly synchronized, and both break immersion.

  2. Procedural everything is viable. Zero external assets means zero loading, zero CORS issues, zero CDN dependencies. The constraints (no photorealistic textures, no recorded audio) actually reinforced the lo-fi horror aesthetic. The whole build is ~606KB across 70 modules.

  3. Match the spatial query to the job. Dot-product checks drive the peripheral-vision systems (shadow figure, scarecrow, notes, cornfield pursuer), while interaction uses a center-screen raycast for precise aiming. Picking the right query per system, a broad cone versus a precise ray, lets each one feel natural.

  4. Performance is decided at architecture time. Flat planes, no shadows, merged collision walls, no transparent: true on InstancedMesh: none of these were late optimizations. They were constraints set before I wrote code. Retrofitting performance is always harder.

  5. A linear layout simplifies everything. One z-axis for area detection turned fog transitions, surface switching, event targeting, and collision grouping into trivial comparisons. It wouldn’t scale to an open world, but for a guided horror piece it’s exactly the right constraint.

  6. Structure amplifies atmosphere. Locked doors and keys turned the experience from “wander and wait” into “explore with purpose.” The overhaul (puzzles, house mutations, battery management, multiple endings) pushed that further. Every system gives you a reason to engage with the space.

  7. Atmosphere over threat. The cornfield pursuer never catches you. The Trapped ending is avoidable. The house mutations can’t hurt you. None of the new features add a fail state; they add unease. Horror that punishes exploration teaches players to rush. Horror that rewards attention teaches them to dread what they notice.

  8. Split before you grow. The main.js split (Phase 0) was unglamorous prep, but without it every feature after would have meant editing a 600-line monolith. The context-object pattern made wiring 7 features into the loop trivial: each new system is about 3 lines.


Last updated: March 2026