DEV Community

Cover image for Building a 3D Game in the Browser Humbled Me. Here's Everything That Fought Back.
Arya Koste
Arya Koste Subscriber

Posted on

Building a 3D Game in the Browser Humbled Me. Here's Everything That Fought Back.

June Solstice Game Jam Submission

This is a submission for the June Solstice Game Jam

Coordinate systems. Rotated bounding boxes. A sine wave cycling 637 times per second. A tribute to Alan Turing hidden in binary light. This is how Solstice Village came to be.


The Idea

June 21. The longest day. Light and darkness at their most dramatic inflection point.

I wanted something that felt like the solstice — not a platformer about suns, not a timer counting daylight seconds. I wanted the quiet tension of a village where every window has gone dark on the longest night, and the warmth that spreads as you, one courier with a lantern, carry light back home to home.

Solstice Village is a 3D exploration game built in Three.js. You play as Sol, walking a circular village, talking to keepers, gathering light at the central brazier, and lighting eight dark homes until the sky shifts from midnight indigo to morning gold.

Play it here: https://aryakoste.github.io/solsticeGame/


What You're Actually Playing

The village is a ring. At the centre: a brazier that flickers. Around it at radius 17 units: eight homes, each dark. Between them at radius 11.5: lamp posts. Beyond the homes: 32 swaying trees, fireflies, a deer, a cat, an owl blinking on a post, and — if you find it — a secret grove with glowing mushrooms and a standing stone.

The main game loop is: Talk → Gather → Deliver → Watch the sky change. Each house lit raises dawnAmount by 1/8. That single float drives everything — sky colour, sun position, fog, ambient light, moon fade. The world literally brightens as you play.

But there is a second ending. If you collect all six spirit wisps scattered around the village without lighting a single home, the game takes a different turn entirely. The village stays dark. The grove awakens. The stone hums. A different piece of narration plays. It rewards the player who ignores the instructions and wanders instead — which felt right for a solstice game, where the night itself has value.


Why 3D on the Web Is a Different Beast Entirely

Most developers who've shipped 2D browser games look at a 3D game and think: it's just one more axis, how much harder can it be?

The answer is: in almost every possible way.

In 2D, your world is flat. Movement is x += vx. Collision is rectangle overlap. Camera is a viewport offset. You render sprites front-to-back and call it done. The computer does what your intuition expects.

In 3D, almost nothing does what your intuition expects.

Everything lives in multiple coordinate spaces at once. Your player exists in world space. The camera that watches them exists in view space. Objects like houses have their own local space, rotated relative to the world. When you need to know if the player is colliding with a rotated house, you can't just compare coordinates — you have to transform the player into the house's local space, do the math there, then rotate the result back. Get any step wrong and the player clips through walls or gets ejected into the sky.

Rotations stop being numbers and become order-dependent operations. In 2D, rotating by 30° then 45° gives the same result as rotating by 45° then 30°. In 3D, the order matters. Rotation around X then Y is not the same as Y then X. This is not a quirk — it's fundamental to how 3D rotations work (they aren't commutative). Every character, every NPC, every camera angle in a 3D game is a product of carefully ordered rotations, and one wrong assumption cascades into every object in the scene pointing the wrong direction.

The camera is its own 3D object, not a window. In 2D you scroll a viewport. In 3D, your camera has a position, an orientation, a field of view, a near clip plane, a far clip plane. A third-person camera orbiting a player requires converting spherical coordinates (yaw angle, pitch angle, distance) into Cartesian (x, y, z). That formula is six trigonometry calls per frame, and if any sign is wrong the camera clips underground, or drifts into the player's head, or spins the wrong direction when you drag the mouse.

Lighting is a full second discipline. In 2D you colour pixels. In 3D, light sources emit in all directions, cast shadows that require their own depth-buffer render pass, bounce off surfaces according to material properties (roughness, metalness, normal maps), and interact with ambient light, hemisphere light, and fog — all simultaneously. Getting a scene to feel right at night, then feel like dawn, requires understanding all of those systems well enough to tune them together.

Time is not a given. In 2D games you often get away with position += speed per frame. In 3D you quickly learn that frame rate varies — a phone runs at 30fps, a desktop at 120fps — and if you animate against raw time without delta-time scaling, your game runs fast on powerful hardware and slow on weak hardware. And even delta-time isn't enough: the specific unit of time matters. Miss a milliseconds-to-seconds conversion anywhere and your animation blows up in ways that look completely inexplicable until you work out the arithmetic.

These aren't beginner mistakes you outgrow. They are the permanent operational cost of building in three dimensions. Every feature you add has to be thought through in all three axes, across multiple coordinate spaces, accounting for arbitrary rotation and variable time. And you discover most of them by shipping a bug.

Here's what Solstice Village taught me.


The Technical Part (Where the 3D Tax Got Collected)

Challenge 1: The NPC That Stared at Walls

Every NPC was placed around the village ring facing away from the plaza — backs to the player, faces to their own house wall. Maddening.

The culprit: Three.js r128's Object3D.lookAt(). This is exactly the kind of 3D convention trap that costs hours. lookAt on any object makes the local −Z axis face the target — the camera convention carried over from OpenGL, where cameras look down -Z by default. When you call grp.lookAt(0,0,0), Three.js rotates the object so its back (-Z) faces the origin. Since the character's face is built at local +Z, every NPC literally showed me their back.

The fix required abandoning lookAt entirely and thinking in terms of the angle I actually needed:

// WRONG — makes local -Z face origin (NPC backs to player)
grp.lookAt(0, 0, 0);

// RIGHT — makes local +Z (the face) point toward origin
grp.rotation.y = Math.atan2(-out.x, -out.z);
Enter fullscreen mode Exit fullscreen mode

Math.atan2(dy, dx) gives the angle of a 2D vector. When you negate both components — (-out.x, -out.z) — you get the angle pointing toward the origin from the NPC's position. Since Three.js character faces have eyes at local +Z, this finally made everyone look at the plaza. The same formula drives NPC head-tracking when you walk close:

// Track player — local +Z toward player
const dx = playerPos.x - n.pos.x;
const dz = playerPos.z - n.pos.z;
n.group.rotation.y = Math.atan2(dx, dz);
Enter fullscreen mode Exit fullscreen mode

Two negations, two formulas, same underlying geometry — just applied in opposite directions. The symmetry is elegant once you find it. Finding it is the hard part.


Challenge 2: The Sine Wave That Cycled 637 Times Per Second

The cat was vibrating. The wisps were strobing. Every frame, the cat's Y position was essentially random — not glitching in some obvious broken way, just wrong, jittering too fast to see what it was trying to do.

The animation code looked fine:

g.position.y = Math.abs(Math.sin(now * 4 + r.phase)) * 0.06;
Enter fullscreen mode Exit fullscreen mode

This is where 3D's time problem came to collect. now came from performance.now() — which returns milliseconds. At 60 fps, now increments by ~16.7 per frame. Math.sin(now * 4) means Math.sin(16.7 × 4) = Math.sin(66.8 radians) per frame. One full sine cycle is 2π ≈ 6.28 radians. So per frame, the animation was completing:

66.8 ÷ 6.28 ≈ 10.6 full cycles per frame
Enter fullscreen mode Exit fullscreen mode

At 60 fps, that's 637 cycles per second. The position was sampling a different, effectively random point on the sine curve every single frame. The cat wasn't jittering — it was animating perfectly at ultrasonic speed. You just can't see 637 Hz with human eyes.

Fix: convert milliseconds to seconds before using as a time parameter:

// Cat bob — multiply by 0.004 = (0.001 to convert ms→s) × (4 Hz frequency)
g.position.y = Math.abs(Math.sin(now * 0.004 + r.phase)) * 0.06;

// Wisp float — explicit ms→s conversion
const t = now * 0.001 + w.phase;
w.orb.position.y = w.oy + Math.sin(t * 1.4) * 0.28;
Enter fullscreen mode Exit fullscreen mode

The firefly animation, written earlier, had correctly used now * 0.001. The roamers and wisps were missing it. One factor of 1000. The difference between a cat that bobs at 4 Hz and a cat that vibrates into another dimension.


Challenge 3: The Camera Orbit

Third-person camera in 3D needs two angles: yaw (horizontal rotation around the player) and pitch (vertical tilt). Converting those spherical coordinates to a Cartesian camera position is one of those formulas you derive, get wrong, stare at, re-derive:

const CAM_DIST = 7.5;
const camYaw = Math.PI; // starts behind player, facing −Z toward plaza

const cx = player.x - Math.sin(camYaw) * Math.cos(camPitch) * CAM_DIST;
const cz = player.z - Math.cos(camYaw) * Math.cos(camPitch) * CAM_DIST;
const cy = player.y + 1.4 + Math.sin(camPitch) * CAM_DIST;
camera.position.set(cx, cy, cz);
camera.lookAt(player.position.x, player.position.y + 1.4, player.position.z);
Enter fullscreen mode Exit fullscreen mode

The Math.cos(camPitch) factor collapses the horizontal offset as pitch increases — when you're looking straight down, the camera is directly above. The Math.sin(camPitch) * CAM_DIST lifts the camera vertically as you tilt up. Both terms together trace a sphere of radius CAM_DIST centred on the player.

Then the movement system has to use that same yaw to map WASD onto world directions, so pressing W always means "forward relative to where the camera is looking":

const wx = Math.sin(camYaw) * fwd + Math.cos(camYaw) * strafe;
const wz = Math.cos(camYaw) * fwd - Math.sin(camYaw) * strafe;
player.rotation.y = Math.atan2(wx, wz);
Enter fullscreen mode Exit fullscreen mode

In 2D this is trivial. In 3D, getting the minus signs right between camera orbit and player movement — so that pressing D strafes right relative to the camera, not world-right — takes real attention.


Challenge 4: Collision Detection Across Rotated Space

Two types of colliders in the world: circular (trees, lamp posts, well, NPCs) and oriented box (rotated houses). Circular collision is manageable:

function _resolveCircle(c) {
  const dx = player.x - c.x, dz = player.z - c.z;
  const dist = Math.sqrt(dx*dx + dz*dz);
  const minDist = playerRadius + c.r;
  if (dist < minDist && dist > 0) {
    player.x += (dx/dist) * (minDist - dist);
    player.z += (dz/dist) * (minDist - dist);
  }
}
Enter fullscreen mode Exit fullscreen mode

Box colliders for houses are the full 3D problem. Each house is rotated to face the plaza — so the box's axes aren't aligned with the world. You can't just compare world-space coordinates. You have to:

  1. Transform the player into the house's local coordinate space
  2. Find the closest point on the axis-aligned box in local space
  3. Compute the penetration depth
  4. Transform the push vector back to world space
function _resolveBox(c) {
  const cos = Math.cos(-c.rotY), sin = Math.sin(-c.rotY);
  const dx = player.x - c.cx, dz = player.z - c.cz;
  // step 1: rotate into box local space
  const lx = cos*dx - sin*dz, lz = sin*dx + cos*dz;
  // step 2: nearest point on box surface
  const px = Math.max(-c.hw, Math.min(c.hw, lx));
  const pz = Math.max(-c.hd, Math.min(c.hd, lz));
  const ox = lx - px, oz = lz - pz;
  const d = Math.sqrt(ox*ox + oz*oz);
  if (d < playerRadius && d > 0) {
    const nx = ox/d, nz = oz/d;
    const push = playerRadius - d;
    // step 3: rotate push back to world space
    player.x += (cos*nx + sin*nz) * push;
    player.z += (-sin*nx + cos*nz) * push;
  }
}
Enter fullscreen mode Exit fullscreen mode

Fourteen lines. Two full rotation transforms. This took longer to get right than any other system in the game — and it's the kind of thing you genuinely cannot debug by guessing. You have to understand the coordinate space transform or you'll never know which of the four signs is wrong.


Challenge 5: Dawn as a Single Variable

The whole sky — background colour, sun height, moon opacity, fog, ambient light, directional light — is driven by one number: dawnAmount, a float from 0 (full night) to 1 (full dawn). This is how you avoid a lighting system that becomes unmanageable. One float. Every system reads it.

// Sky background lerp
const night = new THREE.Color(0x0c1130);
const dawn  = new THREE.Color(0x3a4a82);
scene.background.lerp(night.clone().lerp(dawn, dawnAmount), smoothing);

// Sun rises from underground to sky
const sunY = -12 + dawnAmount * 62; // -12 to +50 units
sunMesh.position.y = sunY;
sunMesh.visible = sunY > -4;

// Moon fades as sun rises
moonMesh.material.opacity = Math.max(0, 1 - dawnAmount * 2.2);

// Scene lighting scales with dawn
sunLight.intensity = 0.35 + dawnAmount * 1.1;
hemi.intensity = 0.7 + dawnAmount * 0.5;
Enter fullscreen mode Exit fullscreen mode

The sun colour shifts from orange to yellow at 40% dawn — mimicking the red horizon of early sunrise shifting to the cleaner yellow of morning:

sunMesh.material.color.setHex(dawnAmount < 0.4 ? 0xff9940 : 0xffe680);
Enter fullscreen mode Exit fullscreen mode

The six wisps scattered around the village each add 8% dawn when collected — a reward for exploration that makes the world respond to curiosity before the main quest requires it. Each wisp also shifts the lantern to its own colour (teal, blue, green) so the light you carry looks different depending on where you've been.


Challenge 6: The Lantern as a Resource, Not a Flag

The original design treated "carrying light" as a binary state — you either had it or you didn't. That worked mechanically, but it meant every trip from the brazier to a home was identical. No tension, no urgency.

The fix was turning the lantern into a fuel system. Light gathered at the brazier fills a lanternFuel float to 1.0. It drains at roughly 1/85th per second — enough for two or three homes per trip at walking pace. As it drains, the lantern physically dims: intensity and opacity both scale with fuel level. The HUD tracks it with colour: gold when full, orange past the halfway point, red and labelled "almost out!" below 20%.

// Each frame while carrying:
lanternFuel = Math.max(0, lanternFuel - dt / 85);
player.userData.lanLight.intensity = 1.8 * lanternFuel;
player.userData.lanMesh.material.opacity = 0.95 * lanternFuel;

if (lanternFuel <= 0) {
  _clearLantern();
  SV.ui.toast('The light faded — return to the brazier');
}
Enter fullscreen mode Exit fullscreen mode

The brazier itself now reacts to how much of the village you've lit. Its flame scales up ~90% in size by full dawn, and its point light gets 70% brighter. The first time you come back to refill and notice the brazier has grown, it feels like the village is waking up with you.


Everything That Made the World Feel Alive

On top of the core systems:

  • 28 fireflies drifting on parametric Lissajous-style curves: x = ox + sin(t × speed) × rx, z = oz + cos(t × 1.3) × rz — the asymmetric frequencies mean they never loop in a straight line
  • Occasional shooting stars that spawn at a random point on the sky sphere, trail five fading ghost spheres, animate with requestAnimationFrame, and clean themselves from the scene after 1.2 seconds
  • Aurora band — 12 semi-transparent planes arranged in a ring at radius 70, each tilted slightly differently for an irregular shimmer
  • 1,200 star particles in a single BufferGeometry draw call
  • Particle burst system — pool of sphere meshes with velocity and gravity, fading by life fraction, spawned on house lighting
  • Chimney smoke — every lit home emits slow expanding grey spheres from its chimney top every 0.75 seconds, growing as they rise and fading out, making lit homes visible from across the village
  • Bell on house lighting — a deep resonant bell rings (four harmonics: 110 Hz, 220 Hz, 440 Hz, 880 Hz, each with a 3.2-second exponential decay) underneath the chord stab, so lighting a home sounds like an event
  • Footstep audio — alternating low tones (78 Hz / 68 Hz) timed to walking pace via a step timer, faster when running. All Web Audio API, no sound files
  • Web Audio API ambient drone — five overlapping sine oscillators at 55 Hz, 82.4 Hz, 110 Hz, 164.8 Hz, and 220 Hz (a harmonic series), each with decreasing gain
  • Touch joystick — thumb delta clamped to 46px radius, normalised to ±1, rotated through camera yaw before being applied to movement
  • Minimap — Canvas 2D drawn every frame: houses as 8×8px squares (dark until lit, gold with glow when lit), NPC positions as dots, player as a filled triangle rotated to match camera yaw
  • House interiors — enter any lit home: a full room with ceiling beams, a hearth with animated flame cones, warm flickering point light, a table, a bookshelf, and a rug. Each keeper has a note left on the table — Ash's has a bread recipe, Tovar's has a watchman's log, Mira's has three letters of SOLSTICE written in binary
  • NPC cross-references — each keeper mentions another by name in their opening line, so the village feels like people who know each other rather than eight isolated quest-givers
  • NPC dawn gathering — past 50% dawn, all eight keepers slowly walk toward the plaza and face the brazier. At full dawn they're clustered together in a silent celebration
  • Cat follows you — approach the cat and press E; it trails behind you for the rest of the game. The deer does the opposite: get within five units and it startles, flags a flee direction, and runs at four times its normal speed until you're ten units away
  • Standing stone reacts to progress — at zero homes lit the stone is cold. Past four it glows. At all eight, the Turing inscription appears
  • 8 unique NPCs with different skin tones, hair styles, and dialogue arcs that change after their home is lit
  • Tree sway — rotation.z and rotation.x oscillating at slightly different frequencies per tree so they never move in sync

Code

Solstice Village

A 3D exploration game built for the June Solstice. You are Sol, the courier. The longest night has fallen and every window in the village has gone dark. Gather light at the central brazier and carry it home to home until the sky turns gold.

Play it here →


Gameplay

  • Talk to each of the 8 keepers to learn their story
  • Gather light at the plaza brazier — your lantern holds ~85 seconds of flame
  • Carry the light to dark homes and deliver it
  • Watch dawnAmount rise from 0 to 1 as the village lights up — every system in the game (sky colour, sun height, fog, moon fade, brazier size, NPC behaviour) is driven by this single float

There is a second ending. Collect all six spirit wisps scattered around the village without lighting any homes, and the game takes a different path.


Controls













Input Action
W






Prize Category

The Alan Turing Prize Category

One of the NPCs, Mira the clockmaker, says this when you approach her dark house:

"Every lit window is a 1, every dark one a 0. Light mine and we add a bit to the dawn."

That's not just flavour text. At the top of the screen runs a binary strip that displays the ASCII value of each letter in "SOLSTICE" — one letter per house you light. When Bell's house lights up, the strip fills in 01010011 (S = 83). When Fen's lights up, it fills 01001111 (O = 79). Eight houses. Eight letters. Eight bytes spelling out the word in light.

S = 01010011
O = 01001111
L = 01001100
S = 01010011
T = 01010100
I = 01001001
C = 01000011
E = 01000101
Enter fullscreen mode Exit fullscreen mode

And in the secret grove at the northeast edge of the map, a standing stone reads:

"Beneath the runes, a name: TURING. And below it — ones and zeros."

Binary as metaphor for light. Light as computation. Every dark window a zero waiting to be flipped.

Google AI in the Process

Google AI Studio and Gemini were part of the development loop throughout — helping reason through Three.js's coordinate conventions, sanity-checking the spherical camera orbit math, and catching the milliseconds-to-seconds unit mismatch before it became a harder bug. When I described the NPC-facing problem, Gemini confirmed the camera-convention issue with lookAt and helped verify the atan2 formula direction before I changed code. The kind of quick second opinion that saves an hour of trial and error at midnight.


Play It

Works in any modern browser, desktop and mobile.

Controls:

  • WASD / left joystick — move
  • Shift — run
  • Click + drag, or touch the right side of the screen — rotate camera
  • E / Talk button — interact with NPCs, brazier, homes, wisps

Find the secret grove in the northeast. Read the standing stone — it says something different depending on how many homes you've lit. Pet the cat. Let the deer startle away from you. Light all eight homes and watch the sky turn.

Or don't light any of them. Collect all six wisps instead, and see what the night has to say.

The longest night ends. That's what the solstice is: not the absence of light, but the moment right before it comes back.


#gamedev #javascript #threejs #webdev

Top comments (0)