Interactive 3D web experience · 2026
Hollowfield
A slow-burn first-person horror experience. A farmhouse that watches you, built entirely in the browser.

Hollowfield — Case Study
A first-person atmospheric 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 in place. Rather than build a horror game from zero, we repurposed the existing scene infrastructure — swapping gallery walls for farmhouse rooms, artwork for horror props, and ambient lighting for oil lamps. The original README still describes the gallery project, a fossil of the pivot.
The key insight: a walking simulator and a horror experience share 80% of their engineering. Both need first-person controls, collision detection, spatial audio, and atmosphere. The remaining 20% — tension pacing, peripheral-vision tricks, environmental storytelling — is where the horror identity lives.
Design Philosophy
Slow-burn dread, not jump scares. The horror comes from accumulating wrongness: lights that flicker slightly more each minute, a shadow glimpsed at the edge of vision that vanishes when you turn, a rocking chair that’s moved since you last looked, a scarecrow that gradually faces you. Nothing ever lunges at the player. The environment itself becomes hostile.
Zero external assets. Every texture is generated procedurally via Canvas 2D (wood grain, wallpaper, dirt, straw, ceiling stains). Every sound is synthesized with Web Audio API (footsteps, wind, creaks, whispers, thuds). This was partly a creative constraint and partly practical — no loading screens waiting on CDN fetches, no format compatibility issues, and a single ~573KB bundle that works offline.
Respect the player’s attention. Discovery notes require both proximity AND looking toward them. Doors require facing them. The shadow figure only appears in peripheral vision. Every system rewards spatial awareness rather than button-mashing.
Architecture Decisions
The HorrorDirector Pattern
The single most important architectural decision: one object controls all horror. HorrorDirector owns a tension float (0→1 over 5 minutes) and every frame pushes that value into subsystems — flicker intensity, ambient volume, desaturation amount, grain strength. No subsystem runs its own timer. No independent escalation.
This was a deliberate reaction against “Christmas tree” horror where every system independently decides to get scarier, producing cacophony. With a central tension source, the experience has a consistent arc: the first minute feels safe, minute two introduces subtle wrongness, minute three makes you doubt your perception, and by minute four the environment is actively hostile.
The EventSystem complements this with 13 one-shot events scheduled across the 5-minute arc (thuds, whispers, shadows, object movement). Events are area-gated — a kitchen whisper only fires if you’re in the kitchen — with early events left area-agnostic since the player starts locked in the kitchen. The gameplay loop adds bumpTension() calls on door unlocks, creating a hybrid curve: time-based rise + discrete jumps at progression milestones. This means every playthrough has a slightly different horror sequence depending on exploration speed, but the overall intensity curve stays consistent.
Factory Functions Over Classes
Every module exports createX() functions returning plain objects — no class, no this, no inheritance. This was a deliberate choice for a project where every system is a singleton. Closures provide private state naturally. The pattern also makes dependency injection explicit: every 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 entire 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). Player area detection is a simple chain of z-threshold comparisons — no spatial indexing, no raycasting. This was a constraint that simplified collision, fog transitions, audio surface switching, and event targeting all at once.
The trade-off is that the world must be fundamentally linear. Branching paths would require a proper spatial system. For this experience — a guided descent from domestic safety into rural unknown — linearity is a feature.
Collision via Raycasting Against Wall Meshes
Rather than a physics engine, collision is 8 raycasts per frame from the player position: 4 cardinal directions + 4 diagonals. Every collidable surface (walls, doors, stall dividers, invisible corn-maze barriers) is collected into a flat wallMeshes[] array passed to the first-person controller.
This approach is dead simple and handles dynamic objects naturally — doors are in wallMeshes[], so when a door swings open, collision follows automatically. The diagonal rays were added to prevent corner-clipping, where axis-aligned checks allow sliding through wall intersections at 45 degrees.
The cornfield’s collision is an interesting optimization: rather than one invisible plane per maze-cell edge (~100+ planes), the system scans the grid and merges adjacent edges into runs, producing ~30-40 merged planes total.
Technical Challenges
Procedural Audio That Doesn’t Sound Procedural
The biggest creative challenge. Synthesized audio tends toward “beep-boop” — obviously fake. Three techniques helped:
-
Band-pass filtering on noise buffers. Footsteps aren’t sine waves — they’re filtered noise with rapid exponential decay. Wood steps use 650/850Hz (sharp), dirt uses 350/500Hz (dull), straw uses 900/1100Hz (crisp). The dual-frequency approach (one per “foot”) prevents repetitive monotone.
-
Convolution reverb. A procedurally generated impulse response (1.2s, stereo) runs through Web Audio’s
ConvolverNodeat 20% wet mix. This single addition transforms flat synthesized sounds into sounds that feel like they exist in a physical space. -
LFO-modulated wind. The ambient wind is white noise through a bandpass filter, but the filter’s center frequency oscillates slowly (0.15Hz) between 40-120Hz. This produces the characteristic rising-and-falling of real wind without any audio samples.
The whisper sound was particularly tricky — it’s noise with a 25Hz amplitude modulation creating “speech rhythm,” high-passed at 2000Hz for breathiness. It’s not intelligible, but the cadence reads as speech at the edge of hearing.
Door Mechanics: Geometry, Pivot, Collision, and Dual Control
Doors seem simple until you implement them. Each door is a thin BoxGeometry with its geometry translated so the hinge edge sits at local origin. swingDir controls which edge is the hinge (+1 = left, -1 = right) and the sign of the open angle. Rotation around Y naturally pivots at the hinge.
The complexity compounds: doors must collide (they’re in wallMeshes[]), animate smoothly (lerp-based with an isAnimating guard), respond to player input (E key, requiring proximity + look direction), support a locked state that blocks both player and horror-system interaction, AND be controllable by the horror system (autonomous creaking). The solution was a clean API surface: toggle() for player interaction, triggerOpen()/triggerClose() for horror events, all sharing the same animation path. The locked property guards both toggle() and triggerOpen(), so horror auto-creak events naturally skip locked doors without special-casing.
Wall geometry at doorways must be split into 2-3 segments (left of door, right of door, lintel above) so the collision gap exactly matches the visual gap. Getting this wrong means either walking through walls or bouncing off invisible barriers in doorways.
Peripheral Vision Detection
The shadow figure should only be visible when you’re NOT looking at it — a surprisingly specific technical requirement. The solution uses the dot product between the camera’s forward vector and the direction to the figure:
dot > 0.7→ direct gaze → fade out fast (5x speed)0 < dot < 0.5→ peripheral vision → fade in slowly to 60% opacity maxdot ≤ 0→ behind player → despawn
The 0.5 threshold corresponds to ~60° off-center — roughly where human peripheral vision begins. The figure uses MeshBasicMaterial (unlit) so it renders as a flat black silhouette regardless of scene lighting, which is crucial for the “was that really there?” effect.
The same dot-product technique is reused for the scarecrow (tracks player when dot < 0.3), discovery notes (visible when dot > 0.4), and door interaction (requires dot > 0.4).
Fog Transitions Without Shader Recompilation
Different areas need different fog: the farmhouse interior is near-black close fog, the cornfield is dark blue-black distant fog. The naive approach — replacing scene.fog with a new THREE.Fog object — causes Three.js to regenerate shader programs for every material in the scene. Instead, FogSystem.js mutates the existing fog object’s properties in place. Same visual result, zero shader recompilation.
The InstancedMesh Transparency Trap
Early in development, corn stalks used transparent: true on their material for the leaf cutout effect. This caused Three.js to sort every instance by distance to camera every frame — destroying performance with 300-500 instances. The fix: alphaTest: 0.3 with an opaque material, which uses the GPU’s discard pipeline instead of CPU-side sorting. A one-line change that recovered 15+ 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 UI is HTML/CSS overlays — no 3D text, no HUD textures. This keeps UI resolution-independent and styleable with standard CSS. The design language uses:
- Space Mono for horror-themed labels (monospaced creates unease)
- DM Sans for readable body text (discovery notes must be legible)
- A dark palette: near-black backgrounds, warm off-white text, sickly amber for discovery titles, dark red accent for horror elements
- CSS transitions for all state changes — JS only toggles classes
The tutorial screen adapts to platform: desktop shows WASD/mouse/keybind icons as inline SVGs; mobile shows the split-zone touch layout with animated gesture hints. Both auto-dismiss after 5 seconds.
Mobile controls use a dynamic-origin joystick — the control ring appears where you touch, not at a fixed position. This avoids the “thumb drift” problem of fixed virtual joysticks.
Storytelling Through Environment
Hollowfield tells its story through 8 discoverable notes placed on physical 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 3D scene, with the full text appearing in a slide-up panel when the player is within 2 meters and looking toward it.
The notes build a fragmented narrative — journal entries, scrawled warnings, mundane documents with unsettling subtext. They’re deliberately sparse and non-sequential, rewarding thorough exploration without requiring it. The horror works whether you read every note or none.
The stables and cornfield currently have no discoveries — a deliberate gap. The farmhouse discoveries establish the domestic normal; the outdoor areas are meant to feel wordless and exposed.
The Gameplay Loop
The original build was a pure free-roam experience — all doors open, tension on a timer, no objectives. The first gameplay pass added locked doors and visible keys. The overhaul transformed it into something with real mechanical depth.
Environmental Puzzles
Keys no longer sit in the open. Each requires a multi-step interaction to discover:
- Kitchen drawer — a small drawer mesh on the counter. Press E to slide it open, revealing the rusty key inside. Simple, but teaches the mechanic.
- 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 1-second animation sequence.
- TV puzzle — gated behind narrative. The player must first read discovery note-08 (the Veterinary Report on the stables), then return to examine the TV. The screen flickers with static and the iron key appears on the shelf. This is the only puzzle that requires reading a specific note, tying mechanical progress to environmental storytelling.
The puzzle system uses a state machine per object (e.g. closed → opening → open) with per-frame animation updates. Keys start with mesh.visible = false and a hiddenUntilPuzzle flag, revealed via keyItems.reveal(id, position) when their puzzle completes.
The House Changes Behind You
When the player enters a newly unlocked room, the rooms behind them mutate:
- Enter hallway → kitchen shelf lamp dims, candle goes out, “Fresh Scratches” note appears on the table
- Enter living room → hallway lamp dims, “Stopped Clock” note appears at the grandfather clock
- Enter stables → TV emits faint static glow, both living room lamps dim, “Static” note appears at the TV
These mutations are one-shot — tracked by a mutated Set. Each mutation calls director.bumpTension(0.05), adding subtle escalation. The mutation discovery notes use noMesh: true so they appear as proximity text without physical paper props, reinforcing the feeling that something changed while you weren’t looking.
The matchbox item (found on a hallway shelf) connects to the mutation system: the kitchen candle goes out during the first mutation, and the matchbox can relight it, revealing a hidden note carved into the table underneath.
Point of No Return
When the player first crosses into the stables, the back door slams shut and locks permanently. The flicker system enters a “dying” pattern for 3 seconds, a thud + creak plays, and tension bumps. There’s no going back. This transforms the stables from a transitional area into a commitment — you’re now heading toward the cornfield whether you’re ready or not.
Flashlight Battery Drain
The flashlight drains from 100% to 0% over ~2 minutes of on-time. A thin battery bar in the top-right shows remaining charge (green → yellow → red). At 0%, the light auto-disables and the F key does nothing until recharged.
Five battery pickups are scattered in dark corners across all areas, each restoring 20%. This creates a resource tension: the flashlight is essential for navigation and note-reading, but using it carelessly means stumbling through the cornfield blind.
The existing batteryFlicker() horror event still works — it temporarily overrides intensity during flicker episodes, then restores to the current battery-scaled level.
The Cornfield Pursuit
An unseen entity follows the player through the maze. The pursuer occupies a maze cell and moves one cell toward the player every 4 seconds using greedy pathfinding (no A* needed for an 11x11 grid — just pick the adjacent corridor cell closest to the player, never occupying the player’s cell).
The pursuer manifests through three channels:
- Corn rustle sounds every 3-6 seconds (high-pass filtered noise bursts, very quiet)
- Shadow figure spawns at the pursuer’s position when 3-8m away AND behind the player (reusing the existing peripheral-vision shadow system)
- Exit beacon flicker — the cornfield exit PointLight randomizes intensity when the pursuer is within 7m
The pursuer never catches the player — it can’t occupy the player’s cell. The threat is atmospheric, not mechanical. The shadow sightings at passed intersections and the approaching rustle sounds create a mounting sense of being followed without ever producing a fail state.
Three Endings
The original single win screen is replaced by three distinct endings:
| Ending | Trigger | Tone |
|---|---|---|
| Escape | Reach cornfield exit | Relief — “YOU ESCAPED” → “HOLLOWFIELD” → PLAY AGAIN |
| Truth | Reach exit with all 8 original discovery notes read | Revelation — “THE TRUTH” → story text about the Mercer family → “You escaped. But it remembers.” |
| Trapped | Tension reaches 1.0 and player hasn’t reached cornfield | Dread — slow 3s fade to black → “You never left.” → “HOLLOWFIELD” → TRY AGAIN |
The Truth ending rewards thorough readers with narrative closure. The Trapped ending punishes hesitation — if you spend too long exploring without progressing, the house wins. Players who’ve made it to the cornfield are safe from the Trapped ending; the pursuit provides enough pressure there.
Advanced Inventory
Two new items beyond keys:
- Matchbox (consumable) — found on a hallway shelf. When in inventory and near the darkened kitchen candle, “[E] Light Candle” prompt appears. Using it consumes the matchbox, relights the candle, and reveals a hidden note carved into the table.
- Photograph (inspectable) — found on the living room side table. Clicking it in the inventory opens a full-screen inspection panel with text that changes across 4 viewings, each more unsettling than the last. The four stages progress 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) rather than proximity + facing checks. Every frame, the ray tests against interactable targets:
- Doors use mesh raycasting (
intersectObjects) since they’re large movable surfaces - Small items (keys, batteries, puzzle items) use sphere hit tests via
ray.distanceSqToPoint()with a 0.2m tolerance radius — precise enough to require aiming but forgiving enough to not frustrate - Puzzle objects use 0.35m sphere tests at their interaction positions
The closest hit along the ray wins, naturally resolving priority by distance rather than a hardcoded type hierarchy. Hovered interactables receive a shimmer effect — a pulsing emissive glow (warm amber for dark materials, intensity boost for already-emissive items). Materials that are shared across instances (door material, battery material) are cloned per mesh so shimmer doesn’t bleed.
Tension Escalation Per Unlock
Each door unlock calls director.bumpTension(0.15), adding a permanent baseline to the time-based tension curve. With three doors, the maximum baseline bump is 0.45 — meaning by the time you reach the cornfield, tension is near maximum regardless of elapsed time. House mutations add an additional 0.05 per room changed, and the point of no return adds 0.1. This ties horror intensity to progression rather than patience.
The Module Split
At ~500 lines, main.js was approaching the 200-line project limit and would have blown past it with 7 new features. The split 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 a flat object with all refs) avoids both global state and prop-drilling. The game loop destructures what it needs; the interaction system gets the full context for flexibility. Late-binding callbacks (like director.setOnTrappedEnding()) handle cases where the callback depends on objects created after the director.
Environmental Detail Pass
The original rooms had 3-13 props each — enough for navigation landmarks but not for immersion. A dedicated detail pass added ~60 new props across all five areas, roughly tripling 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 detail pass also resolved Props.js exceeding the 200-line limit by splitting it into a props/ subdirectory: SharedMaterials.js (22 shared PBR materials), plus one file per area. The original Props.js became a 47-line orchestrator. All new geometry is boxes, planes, cylinders, and circles — no subdivisions, consistent with the project’s flat-geometry performance constraint.
Three new Math.sin() animations (coat sway, curtain sway, lantern sway) use module-scoped counters — zero 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 integrated 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
-
Central authority over escalation. The
HorrorDirectorpattern — one tension source, many consumers — was the highest-leverage decision. Without it, the horror would be either random noise or perfectly synchronized (both immersion-breaking). -
Procedural everything is viable. Zero external assets means zero loading, zero CORS issues, zero CDN dependencies. The creative constraints (no photorealistic textures, no recorded audio) actually reinforced the lo-fi horror aesthetic. The build is ~606KB total with 70 modules.
-
Spatial queries fit the context. Dot product checks power peripheral-vision systems (shadow figure, scarecrow, discoveries, cornfield pursuer), while the interaction system uses center-screen raycasting for precision aiming. Choosing the right spatial query per system — broad cone vs. precise ray — lets each feel natural.
-
Performance is decided at architecture time. The choice to use flat planes, skip shadows, merge collision walls, and avoid
transparent: trueon InstancedMesh — these weren’t optimizations applied later, they were constraints established before writing code. Retrofitting performance is always harder. -
Linear world layout simplifies everything. A single z-axis for area detection meant fog transitions, surface switching, event targeting, and collision grouping all became trivial comparisons. This wouldn’t scale to an open world, but for a guided horror experience, it’s the right constraint.
-
Structure amplifies atmosphere. Adding locked doors and key items transformed the experience from “wander and wait” to “explore with purpose.” The gameplay overhaul — puzzles, house mutations, battery management, multiple endings — deepened this further. Every system creates a reason to engage with the space.
-
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 fail states — they add unease. Horror games that punish exploration teach players to rush; horror games that reward attention teach players to dread what they notice.
-
Split before you grow. The main.js split (Phase 0) was unglamorous prep work, but without it every subsequent feature would have been editing a 600-line monolith. The context-object pattern made integrating 7 features into the game loop trivial — each new system is ~3 lines of wiring.
Last updated: March 2026
Gallery
Gallery coming soon
More visuals from this project are on the way.