Browser-based arena shooter · 2026
Project Slayer
A Halo-style arena shooter running entirely in the browser. Six weapons, grenades, melee, and multiplayer.

Project Slayer — Case Study
A Halo-style arena shooter running entirely in the browser — six weapons, grenades, melee, bot AI, team modes, and multiplayer networking.
Stack: Babylon.js 8.0 · Havok WASM · TypeScript (strict) · Vite · Web Audio API
Scale: ~110 TypeScript files across 17 modules, all under 200 lines each · ~5.8 MB raw / 1.5 MB gzipped
Build Time: ~200 hours · 525+ commits
Play it: playprojectslayer.com
Origin Story
Project Slayer started as a portfolio gallery. The Three.js renderer and pointer-lock controls from gregdesciscio.com were rebuilt into a reusable first-person engine on Babylon.js 8.0 — shipping Havok physics, a character controller, spatial audio, and an Inspector debugger out of the box. That engine (FP Engine) grew across 50+ sessions into a full movement toolkit: crouch-slide, bunny hop chains, mantle with bézier arcs, slide-jump combos.
On March 1, 2026, the engine was forked and combat was layered on top. The question: can a browser-based arena shooter feel like Halo?
Design Pillars
Movement is the skill ceiling. Slide-jumping, mantle-boosting, bunny hop chains — skilled traversal is rewarded, not optional. The arena is 60×60m with three vertical lanes, walkways at 4m, and a tunnel below ground. Every surface is a deliberate choice about who has the advantage.
The Golden Triangle. Guns, grenades, melee — every encounter is a decision between all three. The weapon system handles hitscan, burst fire, multi-pellet spread, toggle scope zoom, and kinematic projectiles through a single data-driven pipeline. Grenades use real Havok physics: frags bounce, plasmas stick on contact.
High TTK, shield-health split. Shields (75) absorb first, regenerate after 4 seconds. Health (100) requires pickups. Fights are engagements, not instant deaths. A headshot multiplier applies to health only — you still need to crack the shield first.
Map control wins games. Power weapons (Rockets, Sniper) spawn on timed pads. Overshield and Active Camo occupy diagonal corners you can’t control simultaneously. Ammo crates give elevated walkways a reason to exist. Knowledge beats camping.
The Mostly-Procedural Constraint
Almost everything in the game is generated from code. The arena is box primitives with PhysicsAggregate. Most weapon and combat sounds are synthesized via biquad filters and noise buffers. UI is HTML/CSS overlays. A handful of custom audio files fill gaps where synthesis couldn’t match the feel, but the vast majority of what you see and hear has no asset file behind it.
This constraint shaped the aesthetic. The arena reads as industrial sci-fi through material variation alone: warm concrete ground floors, cool blue-grey tunnel walls, reflective metal walkways, and emissive trim strips in cyan and team colors. Procedural audio forced each weapon into a distinct sonic identity: the AR’s tinny rattle, the Pistol’s bassy snap, the Shotgun’s deep boom, the Sniper’s supersonic crack with metallic ring.
Match events got the same treatment. Countdown beeps ascend a C major scale. Medal stingers scale in harmonic richness by tier. Shield regen chirps at frequencies quiet enough to feel ambient. Musical intervals convey meaning without samples.
Weapon System Evolution
The weapon system tells the story of how additive design scales. Every weapon was built by adding optional fields to a single WeaponDef interface — never rewriting the core:
| Weapon | What It Added | Lines After |
|---|---|---|
| Assault Rifle | Hitscan, spread bloom, full auto | 170 |
| Pistol | Semi-auto edge detection | 175 |
| Battle Rifle | burstCount field, 3-round burst state machine |
197 |
| Shotgun | pellets field, multi-pellet spread loop |
195 |
| Sniper | scope/zoomFov fields, toggle zoom, sensitivity scaling |
197 |
| Rocket Launcher | projectile field, branching to ProjectileSystem callback |
200 |
By the Rocket Launcher, the file was at exactly 200 lines — the project’s strict file-size limit. Mouse wheel cycling required compacting field declarations from 12 lines to 5 just to fit 6 new lines of scroll logic. Reload-cancel (swapping mid-reload) required extracting a shared _trySwap() helper to eliminate duplication.
The 200-line limit is a deliberate constraint. It forces extraction at the right moment — not prematurely, but before complexity calcifies. WeaponAudio.ts went from 194 lines of copy-pasted buffer generation to 102 lines with a single _gen() helper. Game.ts spawned GameWiring.ts and GameModeSetup.ts when callback wiring outgrew the orchestrator.
Later, adding zoom to 3 more weapons (BR, Pistol, Rocket Launcher) was config-only — zero code changes. The additive pattern had paid off.
The Combat Sandbox
Grenades: Physics-Driven Chaos
Grenades use real Havok physics bodies. Frags bounce off walls with tuned restitution and angular damping. Plasmas stick on contact — but making them stick to characters required a workaround: Havok’s PhysicsCharacterController is kinematic and doesn’t fire collision observables. A per-frame proximity check fills the gap for characters while the physics-based collision path handles walls.
Tuning grenades was an exercise in feel over correctness. Reducing fuse time from 3s to 2s had more impact than any physics parameter — less time for the grenade to roll away from its target. A 15% upward arc bias on throw velocity gives the Halo “toss” feel instead of a laser-straight throw. Angular damping stops the rolling that restitution alone can’t fix.
Melee: The Third Option
Melee uses a cone scan rather than a thin ray — it needs aim magnetism, not precision. Within 2m: instant damage. Within 3.5m inside a 30° aim cone: a lunge dash driven by setDesiredVelocity() that respects Havok wall collisions naturally. Behind the target (dot product > 0.6 between player and target facing): a lethal backsmack dealing 200 damage through full shields.
The lunge system’s registration order matters: MeleeSystem runs between PlayerController and CharacterController in the game loop so its velocity override is the last write before physics integration.
Pickups and Map Economy
Weapon pads use Halo’s pickup flow: walk over with an empty slot to auto-collect, press E to swap when full. The displaced weapon stays on the pad — no loose weapon entities needed. Power weapons drop on death, transforming in-place with a 1-second collect delay to prevent infinite auto-swap loops.
A 3-tier visual hierarchy makes the right things visible at distance: power pickups (Overshield, Camo) are large with dramatic bob, weapons are the middle ground, and utility items (ammo, grenades) are subtle background pickups.
Bot AI
Four bots run a 5-state FSM: Idle → Patrol → Chase → Attack → Retreat. Perception uses a cheapest-to-expensive pipeline — distance check, then cone check, then LOS raycast — to avoid expensive raycasts for distant or off-screen targets.
Navigation runs A* on a 35-node waypoint graph covering all three arena levels. Sub-millisecond pathfinding for box geometry.
Making Bots Feel Human
Raw AI is competent but robotic. Five humanization behaviors close the gap:
- Momentum — bots ramp to speed instead of snapping to full velocity
- Patrol pauses — random brief stops that read as “deciding where to go”
- Combat crouching — deterministic per 2-second window to avoid per-frame flicker
- Post-kill pause — 0.15–0.6 seconds of “did I get them?” hesitation
- Pre-fire delay — randomized reaction time before the first shot on a new target
Combat jumping triggers at strafe direction changes — the natural moment a real player would hop. Head tracking gives bots independent aim: yaw clamped ±60°, pitch ±30°, with an idle sweep that reads as “thinking” when standing still.
Difficulty scales everything: Easy bots accelerate slowly, pause often, and notice you at 35m. Legendary bots snap to speed, react in 100ms, and see across the entire arena.
Team Coordination
In team modes, bots share information through a TeamCoordinator hub: engagement tracking prevents dogpiling, spotted enemies are shared with teammates, patrol routes bias toward pack movement, and flanking strafe direction accounts for friendly positions. A friendlyInLine() raycast prevents bots from shooting through teammates.
Game Modes and Match Flow
The sandbox became a game through a kill attribution pipeline. takeDamage() was widened to accept optional source and weapon parameters — backward-compatible, touching every damage caller. A lightweight CombatEventBus routes all kills to subscribers: scoring, kill feed, medals, stats.
The match state machine handles Countdown → Playing → PostGame → restart. During countdown, bots freeze and the player gets look-only input — camera movement without action. Spawn protection, score limits, time limits, and suicide penalties complete the competitive loop.
Medals track multi-kills (Double, Triple, Overkill, Killtacular within a 4.5-second window), killing sprees, headshots, and assists. Assists use a ring buffer per combatant with 5-second damage windows — bounded memory, automatic staleness cleanup.
Team Slayer scales from 2v2 to 4v4 through dynamic round-robin assignment. Team-aware spawning scores positions by enemy distance (good) and teammate proximity (bonus within 15m), creating natural clustering without explicit formations.
Multiplayer Networking
Five phases took Project Slayer from offline to server-authoritative multiplayer:
Phase 1 — Position relay. Geckos.io (WebRTC data channels) with room codes. Two players see each other moving as humanoid meshes. No combat, no damage — just validation of the networking stack.
Phase 2 — Server authority. The relay became authoritative. A headless Havok physics world runs on the server via NullEngine. Clients send inputs, not positions. Server broadcasts authoritative state at 20Hz. Client-side prediction with input replay reconciles the gap.
Phase 3 — Combat sync. All combat became server-authoritative. Hitscan validation uses pure-math ray-capsule intersection (no Babylon.js scene dependency). Grenades simulate as parabolic trajectories with floor bounce. Six combat systems gained dual-mode support through optional callbacks — null means local authority (offline), set means server authority.
Phase 4 — Match flow. Server owns phases, scoring, and restart. Client-predicted scores via local CombatEventBus give instant UI responsiveness while the server corrects periodically.
Phase 5 — Lag compensation. A 20-tick position history ring buffer per player. Clients stamp shots with the server tick they were rendering when the trigger was pulled. The server rewinds to those positions for hitscan and melee validation, clamped to 300ms maximum rewind. Splash damage isn’t rewound — rockets and grenades detonate at server-authoritative times.
Visual Identity
The arena evolved from uniform greybox to industrial sci-fi through three passes:
- Material differentiation — four PBR presets broke the monotony: cool blue-grey walls, warm concrete ground, darker metal for tactical surfaces, orange crates for cover
- Structural detail — cross-beams spanning the ceiling, duct runs with junction boxes, wall panel insets, horizontal pipe runs, floor grid lines
- Atmosphere — exponential fog for depth, dramatic spotlights for light pools, emissive trim strips in two intensity tiers (structural vs. gameplay-relevant)
GlowLayer caused emissive meshes to bleed through walls — additive compositing ignores depth. The fix: customEmissiveColorSelector suppresses glow on specific meshes (bots, dummies) while letting structural emissives shine. Bloom carries all the punch, and tight kernel sizes (16) keep it sharp.
Bot meshes evolved from single boxes to 10-part rounded humanoids with headshot detection. Weapon viewmodels are procedural: 3–5 box and cylinder parts per weapon, two-tone PBR materials, per-weapon fire kick, reload style (magazine dip vs. pump rhythm), and melee thrust animations.
Ragdoll physics on death: pooled Havok bodies with ball-and-socket joint constraints, death impulse from the killer’s direction, alpha fade-out. The constraints survive motion-type changes (STATIC↔DYNAMIC), so toggling isEnabled gives explicit control without dispose/recreate overhead.
Architecture Highlights
System composition. Every system implements ISystem { update(dt): void; dispose(): void }. The GameLoop iterates without knowing types. Systems communicate through shared state and callbacks — no event bus for per-frame data, no global state.
Zero-allocation render loop. Pre-allocated scratch vectors at module scope. No new Vector3() in any update(). Weapon swap tracking uses a pre-filled array instead of per-frame [inp.swap1, ...]. Explosion VFX are pre-pooled (meshes + lights created once, reused via enable/disable). Aim assist caches target lists every 500ms instead of scanning all scene meshes every frame.
Data-driven identity. Weapon reticles, audio IDs, and viewmodel parameters are config fields on WeaponDef — not property sniffing (if w.burstCount... if w.pellets...). Adding a weapon is a single config object. Adding zoom to three weapons was three config changes, zero code.
Dual-mode combat. Every combat system works offline (local authority for bots and single-player) or online (server authority for multiplayer) through optional callback injection. Null means run locally. Set means send intent and wait for confirmation. Zero overhead when offline.
Lessons Learned
-
The 200-line limit forces good architecture. It’s never convenient in the moment — compacting field declarations, extracting helpers, splitting orchestrators — but it catches complexity before it calcifies. Every extraction was overdue by the time the limit forced it.
-
Additive interfaces scale.
burstCount,pellets,scope,projectile— each optional field onWeaponDefkept the weapon system generic while adding radically different behaviors. The alternative (aFireModeenum or per-weapon classes) would have required rewriting the system with every weapon. -
Procedural constraints become identity. Generating most assets from code means minimal loading, no CDN dependencies, and a near-instant game load. It also means every weapon sounds different because it has to — most sounds are synthesized from scratch rather than pulled from a shared library.
-
Humanization matters more than intelligence. Bot momentum, patrol pauses, post-kill hesitation, and pre-fire delays had more impact on perceived intelligence than any A* improvement. Players read intention from movement quality, not decision quality.
-
Browser physics is real physics. Havok WASM runs headless in Node.js for authoritative servers. Characters slide down ramps correctly. Grenades bounce off walls. Ragdolls tumble. The same physics engine powers both client and server with identical behavior — the dream of shared simulation logic is achievable in the browser.
-
Lag compensation is simpler than it sounds. Client stamps shots with the server tick. Server rewinds position history. Ray-capsule intersection validates the hit. The hardest part was threading
lastServerTickthrough callback closures — the actual rewind is a ring buffer index lookup.
Last updated: March 2026
Gallery
Gallery coming soon
More visuals from this project are on the way.