People assume a 3D first-person shooter needs Unity or Unreal and a fat download. The browser FPS I built is a wave-based zombie survival game that runs at 60 FPS on a tab you can open in a second — built with Three.js and TypeScript, no game engine, no install. Here's how the engine fits together.
Three.js is a renderer, not an engine
The first thing worth being clear about: Three.js draws 3D geometry with WebGL, but it doesn't make a game. There's no built-in concept of a player, an enemy, a weapon, health, or "you got shot". All of that is code I wrote on top. So the project is really a small custom game engine that happens to use Three.js for the pixels.
The structure is a classic game loop fanning out to managers:
Main render loop
└─ Game Manager
├─ Input Manager (keyboard+mouse / touch)
├─ Collision Detection (raycaster)
└─ Entity Manager
├─ Player Controller
└─ Enemy AI array
The render loop and pointer lock
The heart of it is a requestAnimationFrame loop. Each frame: read input,
advance the player, update every enemy, run collision checks, then render. Mouse-look uses
the browser's PointerLock API — when you click into the game the cursor is
captured and raw mouse movement turns the camera, which is what makes it feel like a native
FPS rather than a thing in a web page.
Shooting: hitscan raycasting
Weapons are hitscan, not simulated projectiles. When you fire, the engine casts a ray straight out from the centre of the camera and asks which enemy bounding boxes it intersects; the nearest intersection is the hit. This is exactly how fast-firing weapons work in most shooters — there's no bullet to animate across the room, the shot resolves the instant you click. Each weapon just changes the numbers around that ray:
- Pistol — reliable starter, single accurate ray
- Rifle — high fire rate, medium damage
- Shotgun — several rays with a random angular spread for close-range spread
- SMG — spray-and-pray, high mobility
Enemies: wave spawning and vector pathfinding
Zombies spawn in waves, with each wave scaling their stats up so the game gets harder. Their "AI" is deliberately simple and cheap: every frame each enemy computes the vector from itself to the player's position and translates a step along it — shortest path, straight at you. With dozens of enemies on screen, simple-and-cheap beats clever-and- expensive every time.
There's also a mystery box for weapon drops, driven by random number generation feeding a little state machine — roll, reveal, grant weapon, cool down.
Hitting 60 FPS in a browser tab
A browser game lives or dies on frame rate, and the wins here are unglamorous:
- Low-poly geometry. Fewer triangles to push every frame.
- Frustum culling. Objects outside the camera's view aren't drawn at all.
- Object pooling. Bullets and enemies are reused from a pool instead of being allocated and thrown away constantly — which is what keeps the garbage collector from stuttering the frame rate at the worst possible moment.
Spatial audio is approximated with Howler.js, and the whole thing is bundled with Vite and deployed to GitHub Pages by a GitHub Actions workflow on every push.
One input manager, desktop and mobile
The same build runs on a phone. A unified Input Manager maps keyboard + mouse on desktop and on-screen touch joysticks on mobile to the same internal actions, so the player controller doesn't know or care which device it's on. Writing input as an abstraction layer from the start is one of those decisions that pays for itself the moment you add a second platform.
Why bother building it raw?
You could ship this faster in a real engine. But building the loop, the raycaster and the entity system by hand is the best way I know to actually understand why engines are shaped the way they are — and the payoff is a 3D game with zero install friction. Someone clicks a link and they're playing.