The idea: a browser game that actually feels like a game

There's a particular kind of game developer optimism that goes: "I'll just use Three.js and keep it simple." Two months and 3,094 lines later, you have a procedurally generated open world with biome-aware terrain coloring, a five-weapon arsenal, real-time WebSocket multiplayer, adaptive AI guards, mobile joystick controls, and a day-night color cycle. Pumpkin Collector started as a weekend experiment and became something we're genuinely proud to ship.

The concept is deliberately charming: roam a Minecraft-flavored landscape, collect pumpkins scattered across fields and mountains, steal them from patrolling guards without getting caught, protect villagers from wolf-like predators, earn coins, and upgrade your loadout. It sounds simple. Building it was not.

This article is the full technical story — every architecture decision, every tradeoff, and a few things we'd do differently. If you're building a browser game in 2026, or you're just curious how much game logic you can pack into a single vanilla JavaScript class, read on.

💡
Play it right now Pumpkin Collector runs entirely in your browser with no download, no account, and no install. Solo mode needs nothing beyond a static file server. Multiplayer needs a 10-line Node.js WebSocket server.

Architecture: one class, one file, zero regrets

The most unusual thing about Pumpkin Collector is its architecture — or rather, its deliberate lack of one. The entire game lives inside a single ES6 class called PumpkinCollectorGame, instantiated once as a global at the bottom of game.js. No modules, no bundler, no webpack config, no tsconfig, no build step. You open index.html in a browser and it works.

"The entire game is one class. That's not a bug — it's a deployment model."

Game architecture decision, day 1

This decision was intentional. Browser games have a distribution superpower that desktop games don't: a URL. Every build step you add is friction between your game and the player. By keeping everything in a single script tag, Pumpkin Collector can be hosted on any CDN, embedded in any page, and forked by any developer in seconds. The trade-off is that game.js is 3,094 lines long, but that's a readability problem, not a performance problem.

Project structure
pumpkin-collector/
├── index.html ← HUD, shop UI, touch controls, CSS (457 lines)
├── game.js ← All game logic, single class (3,094 lines)
├── ws_server.js ← WebSocket relay server, Node.js (111 lines)
├── background-score.mp3
├── leopard-attack.mp3
├── pistal-shoot.mp3
├── rifle-gunshot.mp3
└── walking-sound.mp3

The constructor as the entire game state

The constructor of PumpkinCollectorGame does something that would make a software architect wince: it declares every piece of mutable game state as a property. The Three.js scene, camera, renderer, the clock, the noise generator, weapon definitions, audio elements, difficulty profiles, the player object, every array of NPCs — all of it lives on this. This turns the entire game state into a single plain JavaScript object, which makes debugging trivially easy (open the console, type game.player) and serialization straightforward if you ever want to add save states.

JavaScript — game.js (constructor excerpt)
class PumpkinCollectorGame {
  constructor() {
    // Three.js core
    this.scene    = null;
    this.camera   = null;
    this.renderer = null;
    this.clock    = new THREE.Clock();
    this.noise    = new SimplexNoise(42);

    // World
    this.worldSize = 200;  // ±200 units → 400×400 world
    this.gravity   = -25;

    // Entity arrays
    this.pumpkins  = [];
    this.guards    = [];
    this.predators = [];
    this.villagers = [];
    this.animals   = [];
    this.bullets   = [];
  }
}

The game loop itself — animate() — is a classic requestAnimationFrame callback. Every frame it ticks ten subsystems in order: player physics, guard AI, animal AI, predator AI, villager AI, bullet simulation, particle effects, weapon animation, skybox color update, proximity hint detection, and finally the Three.js render call. DeltaTime is capped at 50ms to prevent physics tunneling on slow frames.

Procedural terrain with Simplex Noise

Pumpkin Collector's world is 400×400 units, generated fresh each session from a seeded Simplex Noise function. The noise implementation is bundled directly in game.js — a compact, self-contained IIFE — so there's no external noise library to load. Seed 42 means the world is always identical, which is important for gameplay balance: we know where huts can reasonably spawn, where guards won't get stuck, and roughly how many open plains exist for pumpkin placement.

The two-layer height formula

Rather than a single noise octave, the terrain uses a flatness mask — a second noise sample at a much lower frequency (scale 0.005) that determines whether any given region should be gently rolling plains or dramatic highland. If the flatness value is below 0.4, the region is flat: terrain height is capped at roughly 2 units with subtle rolling. If flatness exceeds 0.4, the full range kicks in, producing hills and mountains up to 40 units tall. The result is that roughly 70% of the world is navigable plains — flat enough for pumpkins, guards, and players to move around — while the remaining 30% provides dramatic scenery and natural barriers.

JavaScript — getHeight(x, z)
getHeight(x, z) {
  const flatness = (this.noise.noise2D(x * 0.005, z * 0.005) + 1) / 2;

  if (flatness < 0.4) {
    // Plains: gentle roll, max ~2 units
    return this.noise.noise2D(x * 0.02, z * 0.02) * 2;
  }
  // Highland: full range up to ~40 units
  return this.noise.noise2D(x * 0.015, z * 0.015) * 40
       + this.noise.noise2D(x * 0.05,  z * 0.05)  * 8;
}

Biomes and vertex coloring

The terrain mesh is a single THREE.PlaneGeometry covering the entire 400×400 world. Biome coloring is applied per-vertex at generation time, not via a texture — this keeps the memory footprint tiny and produces that characteristic Minecraft-like flat-shaded look without any UV mapping. A second noise sample (moisture, scale 0.01) combined with the height and flatness values classifies each vertex into one of six biomes: water (blue-grey), mountain (stone/snow), forest (dark green), field (sandy yellow), plains (bright green), or grassland (medium green).

🌿
Why vertex colors instead of a texture atlas? Vertex colors require zero texture fetches during rendering. Combined with flatShading: true, they produce the exact aesthetic we wanted — chunky, expressive, fast — with a single draw call for the entire terrain.

There's one important performance caveat here: height is computed live every time getHeight(x, z) is called. There's no baked heightmap array. This means every collision check, every spawn placement, every frame's player physics call re-evaluates the noise function. On modern CPUs this is imperceptibly fast, but it's a design decision we'd revisit if performance became a concern on lower-end mobile devices.

AI systems: guards, predators, and villagers

Three distinct AI archetypes populate the world, each with their own behavioral finite state machine. They're not complex — each entity has two or three states — but their interactions with each other and with the player create surprisingly emergent gameplay moments.

Guards: detection, pursuit, and punishment

Guards patrol random waypoints and scan for the player using a dot-product field-of-view check. This is worth emphasizing: there is no raycasting, no occlusion, no line-of-sight through terrain. If the player is within detection range and within the forward-facing cone, the guard enters chase mode regardless of whether a mountain is in the way. This is a conscious design decision — raycasting every guard against terrain every frame would be expensive, and the gameplay consequence (you can't actually hide behind hills from an alerted guard) turned out to be a meaningful difficulty driver rather than a bug.

Detection range, chase speed, and penalty values all scale with the chosen difficulty profile, allowing the same AI code to serve easy, normal, and hard modes without branching logic.

Difficulty Guards Predators Pumpkins Catch HP Loss
Easy 8 5 46 10 HP
Normal 12 8 35 15 HP
Hard 16 12 26 22 HP

Predators: the danger compass system

Predators (wolf-like entities) hunt animals and villagers. When a predator locks onto a target, a dynamic danger indicator appears in the HUD — a directional compass card showing which villager is under threat and from which bearing. This was one of the most impactful UX additions in the whole game: it transforms abstract NPC violence happening off-screen into an urgent spatial problem for the player to solve.

The predator AI has a _forceAttackTimer that guarantees the first attack happens no earlier than 10 seconds after game start, giving new players time to orient themselves before the world becomes hostile.

Villagers: flee and reward

Villagers wander randomly between waypoints. When a predator enters their threat radius, they flee. If the player kills the predator before the villager is harmed, a coin reward drops — a simple mechanic that creates a compelling moment-to-moment objective loop without any quest system.

The weapons arsenal

Pumpkin Collector ships with five weapons that span a wide range of playstyles. Sword and pistol are free starting equipment; shotgun, rifle, and sniper must be unlocked with coins earned by selling pumpkins. The progression feels genuinely earned because early-game resources are scarce.

Weapon Type Damage Range Cooldown Cost
Sword Melee 15 + level×3 3u + upgrades 0.5s Free
Pistol Gun 12 40u 0.4s Free
Shotgun Gun 8×5 pellets 20u 0.8s 30 coins
Rifle Gun 25 80u 0.7s 60 coins
Sniper Gun 50 150u 1.5s 100 coins

Ranged weapons fire discrete bullet meshes — small box geometries that travel through the scene each frame — rather than instant raycasts. This is a deliberate visual choice: watching a sniper round cross the full 150-unit engagement range is more satisfying than an invisible raycast hit, and the bullet travel time adds a subtle skill gap for long-range shots against moving targets.

The sword is uniquely upgradeable via the shop, increasing both damage (by 3 per level) and range (by 0.5 units per level). This makes melee a viable late-game option against predators at close range, and gives players a secondary upgrade path beyond buying new guns.

Real-time multiplayer over WebSockets

Multiplayer in Pumpkin Collector is opt-in and elegantly minimal. Players share a 5-digit room code; the WebSocket server — all 111 lines of it — broadcasts each player's position, rotation, health, and animation state to everyone else in the room. Remote players appear as simple humanoid meshes with animated limbs, labeled with their player number.

⚠️
Honest caveat: it's position relay only The multiplayer server performs zero game logic validation. Pumpkin collection is honor-system — if two players grab the same pumpkin simultaneously, the client that processed the collect first wins, and the other player's count simply desyncs. This is a known limitation, not a bug we haven't fixed yet.
JavaScript — WebSocket sync payload
// Broadcast own state at max 20fps
_syncMpState(dt) {
  if (!this.mp.connected) return;
  this.mp.syncTimer += dt;
  if (this.mp.syncTimer < 0.05) return;  // 20 fps cap
  this.mp.syncTimer = 0;

  this.mp.ws.send(JSON.stringify({
    type: 'state',
    pos:  this.player.position,
    yaw:  this.yaw,
    hp:   this.player.hp,
    anim: this.player.isSprinting ? 'sprint' : 'walk'
  }));
}

For a casual browser game this is the right tradeoff. Full server-side authoritative multiplayer would require a complete rewrite of the game loop and a significantly more complex server. The position-relay approach means any developer can spin up the multiplayer backend with a single node ws_server.js command and have friends playing together in under a minute.

Mobile-first with touch controls

A browser game that only works on desktop is leaving a majority of its potential audience behind — particularly in markets across Europe and the US where mobile gaming now accounts for more than half of all casual gaming sessions. Pumpkin Collector detects touch capability on load and swaps in a full virtual control layer.

The left side of the screen hosts a virtual joystick with a 130px base and 42px maximum thumb radius. The right side handles camera look — any touch drag on the right 45% of the screen that isn't the joystick is interpreted as a look input, mirroring the dual-stick layout of every major mobile FPS on the market. Action buttons (attack, jump, sprint, collect, shop) cluster in the bottom-right corner.

📱
Portrait mode guard On mobile, Pumpkin Collector locks to landscape orientation via screen.orientation.lock('landscape') and requests fullscreen automatically. If a player somehow loads it in portrait mode, a CSS media query (max-width: 900px and (orientation: portrait)) triggers a rotating animation overlay asking them to flip their device.

Head bob — a ±0.05 unit vertical camera oscillation during walking and ±0.08 during sprinting — is present on mobile too, adding to the sense of physical presence in the world. It's a small detail that disproportionately affects how "game-like" the experience feels.

Performance decisions we actually regret

The game works well on modern hardware, but there are several architectural decisions that would become bottlenecks at scale or on constrained devices. Being honest about them matters for anyone using this codebase as a learning resource.

No terrain chunking. The entire 400×400 world is a single THREE.PlaneGeometry. This works because the geometry is static after generation, but it means the full terrain is always in the GPU regardless of where the player is. A chunked approach (loading and unloading 16×16 sections) would improve memory usage and enable larger worlds.

Live height computation. As mentioned earlier, getHeight(x, z) re-evaluates Simplex Noise every call. On a fast CPU this is fine. On a mid-range Android device running a complex frame, it adds up. Baking a heightmap array at generation time would eliminate this entirely.

Guard FoV is dot-product only. No occlusion means guards can "see" through mountains. For the current world scale this reads as a difficulty feature, but at larger scales or in denser geometry it would feel broken.

No object pooling. Bullets, particles, and floating text are created and destroyed as JavaScript objects every time they're needed. At high fire rates this creates GC pressure. An object pool for bullets would be a straightforward optimization for anyone looking to improve the codebase.

What comes next

The roadmap for Pumpkin Collector is driven by what players actually ask for. A few items stand out as high-impact and technically tractable in the near term.

A minimap — a small canvas overlay rendering the world top-down — is the most-requested feature. It's achievable with a secondary orthographic camera rendering to a WebGLRenderTarget, then drawn onto a 2D canvas element in the corner of the HUD.

A proper day-night cycle with actual light level changes (rather than just the current subtle fog color shift) would dramatically improve atmosphere. The infrastructure is there — a DirectionalLight and a HemisphereLight already exist in the scene. Animating their intensity and color over a 10-minute cycle is a well-scoped addition.

Server-side multiplayer validation is the most complex item on the list. Moving pumpkin collection acknowledgment to the server would eliminate the race condition in multiplayer, but it requires the server to maintain a copy of world state — a significant scope increase for what is currently a 111-line relay script.

Longer term: weather effects, a quest system, more biome variety (desert and snow regions are already partially designed), and pack behavior for predators — wolves that coordinate hunts rather than attacking solo — are all ideas we're excited about.

🚀
Want to contribute? The entire codebase is readable in two files. If you're learning Three.js, game physics, or procedural generation, forking Pumpkin Collector and adding one of the features above is an excellent hands-on project with a working starting point.
three.js vanilla-js browser-game procedural-generation simplex-noise websocket-multiplayer game-dev webgl mobile-gaming indie-dev