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.
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 1This 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.
├── 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.
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.
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).
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.
// 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.
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.