First Person Arena Shooter · 2026
Project Slayer
A Halo-style arena shooter with six weapons, grenades, melee, and multiplayer, right in your browser.

Project Slayer
A Halo-style arena shooter that runs entirely in a tab. Six weapons, grenades, melee, bots that flank, team modes, and real multiplayer.
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
This started as a portfolio gallery. The Three.js renderer and pointer-lock controls behind gregdesciscio.com got rebuilt into a reusable first-person engine on Babylon.js 8.0, with Havok physics, a character controller, spatial audio, and an inspector for debugging all in the box. That engine (FP Engine) grew across 50-plus sessions into a proper movement toolkit: crouch-slide, bunny hop chains, mantling on bézier arcs, slide-jump combos.
On March 1, 2026, I forked the engine and started layering combat on top. The whole project came down to one question: can a shooter in the browser actually feel like Halo?
Design Pillars
Movement is the skill ceiling. Slide-jumping, mantle-boosting, bunny hop chains. Good traversal is rewarded, not optional. The arena is 60x60m with three vertical lanes, walkways at 4m, and a tunnel running below ground. Every surface is a decision about who gets the advantage.
The golden triangle. Guns, grenades, melee. Every encounter is a choice between all three. The weapon system runs hitscan, burst fire, multi-pellet spread, toggle scope zoom, and kinematic projectiles through one data-driven pipeline. Grenades use real Havok physics: frags bounce, plasmas stick on contact.
High time-to-kill, shields over health. Shields (75) absorb first and regenerate after four seconds. Health (100) only comes back from pickups. Fights are engagements, not instant deaths. The headshot multiplier applies to health only, so you still have to crack the shield first.
Map control wins games. The power weapons (Rockets, Sniper) spawn on timed pads. Overshield and Active Camo sit in opposite corners you can’t hold at the same time. Ammo crates give the elevated walkways a reason to exist. Knowing the map beats camping it.
Almost Everything Is Procedural
Nearly all of the game is generated from code. The arena is box primitives wrapped in PhysicsAggregate. Most of the weapon and combat sounds are synthesized from biquad filters and noise buffers. The UI is HTML and CSS on top. A handful of custom audio files cover the spots where synthesis couldn’t nail the feel, but the vast majority of what you see and hear has no asset file behind it at all.
That constraint became the look. The arena reads as industrial sci-fi through material variation alone: warm concrete on the ground floors, cool blue-grey tunnel walls, reflective metal walkways, emissive trim in cyan and team colors. Procedural audio forced every weapon into its own voice: the AR’s tinny rattle, the pistol’s bassy snap, the shotgun’s deep boom, the sniper’s supersonic crack with a metallic ring behind it.
Match events got the same treatment. Countdown beeps climb a C major scale. Medal stingers get richer as the tier goes up. Shield regen chirps quietly enough to sit under everything else. The musical intervals carry meaning without a single sample.
Weapon System Evolution
The weapon system is the clearest example of additive design paying off. Every weapon was built by adding optional fields to a single WeaponDef interface. The core never got rewritten.
| 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 hit exactly 200 lines, which is the project’s hard limit. Adding mouse-wheel cycling meant compacting the field declarations from 12 lines down to 5 just to make room for 6 lines of scroll logic. Reload-cancel (swapping weapons mid-reload) meant pulling out a shared _trySwap() helper to kill the duplication.
The 200-line limit is on purpose. It forces you to extract at the right moment: not too early, but before the complexity sets like concrete. WeaponAudio.ts went from 194 lines of copy-pasted buffer generation down to 102 with a single _gen() helper. Game.ts spun off GameWiring.ts and GameModeSetup.ts once the callback wiring outgrew the orchestrator.
Later, adding zoom to three more weapons (BR, Pistol, Rocket Launcher) was config only. Zero code changes. The additive pattern had earned its keep.
The Combat Sandbox
Grenades: Physics-Driven Chaos
Grenades are real Havok physics bodies. Frags bounce off walls with tuned restitution and angular damping. Plasmas stick on contact, but making them stick to characters needed a workaround: Havok’s PhysicsCharacterController is kinematic and never fires collision observables. So a per-frame proximity check handles characters while the normal physics collision path handles walls.
Tuning grenades was all about feel, not correctness. Dropping the fuse from 3s to 2s did more than any physics parameter, because it gives the grenade less time to roll away from where you wanted it. A 15% upward bias on the throw velocity gets that Halo lob instead of a flat laser toss. Angular damping kills the rolling that restitution alone can’t.
Melee: The Third Option
Melee uses a cone scan instead of a thin ray, because it needs aim magnetism, not precision. Inside 2m: instant damage. Inside 3.5m and a 30-degree aim cone: a lunge dash driven by setDesiredVelocity(), which respects Havok wall collisions for free. Behind the target (dot product above 0.6 between the two facings): a lethal backsmack that does 200 damage straight through full shields.
Registration order matters here. MeleeSystem runs between PlayerController and CharacterController in the loop, so its velocity override is the last write before physics integrates.
Pickups and Map Economy
Weapon pads follow Halo’s flow: walk over one with an empty slot and it auto-collects, press E to swap when you’re full. The weapon you drop stays on the pad, so there are no loose weapon entities to manage. Power weapons drop on death and transform in place, with a one-second collect delay so you don’t get caught in an infinite auto-swap loop.
A three-tier visual hierarchy keeps the right things readable at a distance: power pickups (Overshield, Camo) are big with a dramatic bob, weapons sit in the middle, and utility items (ammo, grenades) stay subtle in the background.
Bot AI
Four bots run a five-state machine: Idle, Patrol, Chase, Attack, Retreat. Perception runs cheapest checks first: a distance check, then a cone check, then a line-of-sight raycast, so it never wastes an expensive raycast on something far away or off-screen.
Navigation is A* over a 35-node waypoint graph covering all three levels of the arena. Pathfinding is sub-millisecond for this box geometry.
Making Bots Feel Human
Raw AI is competent but obviously a robot. Five behaviors close the gap:
- Momentum. Bots ramp up to speed instead of snapping to full velocity.
- Patrol pauses. Brief random stops that read as “deciding where to go.”
- Combat crouching. Decided once per two-second window so it doesn’t flicker every frame.
- Post-kill pause. 0.15 to 0.6 seconds of “did I get them?” hesitation.
- Pre-fire delay. A randomized reaction time before the first shot at a new target.
Combat jumping fires on strafe direction changes, the exact moment a real player would hop. Head tracking gives bots aim that’s independent of their body: yaw clamped to plus or minus 60 degrees, pitch to plus or minus 30, with an idle sweep that looks like thinking when they’re standing still.
Difficulty scales all of it. Easy bots accelerate slowly, pause a lot, and don’t notice you until 35m. Legendary bots snap to speed, react in 100ms, and see the whole arena.
Team Coordination
In team modes, bots share what they know through a TeamCoordinator hub. Engagement tracking stops them from dogpiling one target, spotted enemies get shared with teammates, patrol routes bias toward moving as a pack, and flanking direction accounts for where friendlies are. A friendlyInLine() raycast keeps bots from shooting through their own team.
Game Modes and Match Flow
The sandbox turned into a game through a kill-attribution pipeline. takeDamage() was widened to take optional source and weapon parameters, backward-compatible, but it touched every damage caller. A small CombatEventBus routes every kill to its subscribers: scoring, kill feed, medals, stats.
The match state machine runs Countdown, Playing, PostGame, restart. During the countdown, bots freeze and the player gets look-only input: you can move the camera but not act. Spawn protection, score limits, time limits, and suicide penalties round out the competitive loop.
Medals track multi-kills (Double, Triple, Overkill, Killtacular inside a 4.5-second window), sprees, headshots, and assists. Assists use a per-combatant ring buffer with five-second damage windows, so memory stays bounded and stale entries clean themselves up.
Team Slayer scales from 2v2 to 4v4 through round-robin assignment. Team-aware spawning scores positions by distance from enemies (good) and closeness to teammates (a bonus within 15m), which produces natural clustering without any explicit formation logic.
Multiplayer Networking
Getting from offline to server-authoritative multiplayer took five phases.
Phase 1: position relay. Geckos.io (WebRTC data channels) with room codes. Two players see each other move as humanoid meshes. No combat, no damage, just proving the networking stack works.
Phase 2: server authority. The relay became the authority. A headless Havok world runs on the server through NullEngine. Clients send inputs, not positions. The server broadcasts authoritative state at 20Hz, and client-side prediction with input replay smooths over the gap.
Phase 3: combat sync. All combat moved server-side. Hitscan validation uses pure-math ray-capsule intersection with no dependency on the Babylon.js scene. Grenades simulate as parabolic trajectories with a floor bounce. Six combat systems got dual-mode support through optional callbacks: null means local authority (offline), set means server authority.
Phase 4: match flow. The server owns phases, scoring, and restart. The client predicts scores through its local CombatEventBus for instant UI, and the server corrects it periodically.
Phase 5: lag compensation. Each player gets a 20-tick ring buffer of past positions. Clients stamp every shot with the server tick they were rendering when the trigger went down. The server rewinds to those positions to validate hitscan and melee, clamped to a 300ms maximum rewind. Splash damage doesn’t get rewound, since rockets and grenades detonate on server-authoritative timing.
Visual Identity
The arena went from a plain greybox to industrial sci-fi in three passes.
- Material differentiation. Four PBR presets broke up the monotony: cool blue-grey walls, warm concrete ground, darker metal for tactical surfaces, orange crates for cover.
- Structural detail. Cross-beams across the ceiling, duct runs with junction boxes, wall panel insets, horizontal pipe runs, floor grid lines.
- Atmosphere. Exponential fog for depth, dramatic spotlights for pools of light, emissive trim at two intensity tiers (structural vs. gameplay-relevant).
GlowLayer had emissive meshes bleeding through walls, because additive compositing ignores depth. The fix was customEmissiveColorSelector, which suppresses glow on specific meshes (bots, dummies) while letting the structural emissives stay lit. Bloom carries all the punch, and a tight kernel (16) keeps it sharp.
Bot meshes grew from single boxes into 10-part rounded humanoids with proper headshot detection. Weapon viewmodels are procedural too: three to five box and cylinder parts each, two-tone PBR materials, per-weapon fire kick, reload style (a magazine dip vs. a pump rhythm), and melee thrust animations.
Death gets ragdoll physics: pooled Havok bodies with ball-and-socket joints, a death impulse coming from the killer’s direction, and an alpha fade-out. The joint constraints survive motion-type changes (STATIC to DYNAMIC and back), so toggling isEnabled gives you clean control without the cost of disposing and recreating them.
Architecture Highlights
System composition. Every system implements ISystem { update(dt): void; dispose(): void }. The GameLoop iterates over them without knowing what any of them are. Systems talk through shared state and callbacks: no event bus for per-frame data, no global state.
Zero-allocation render loop. Scratch vectors are pre-allocated at module scope. There’s no new Vector3() inside any update(). Weapon swap tracking uses a pre-filled array instead of building [inp.swap1, ...] every frame. Explosion VFX are pooled (meshes and lights created once, reused with enable/disable). Aim assist caches its target list every 500ms instead of scanning all scene meshes each frame.
Data-driven identity. Weapon reticles, audio IDs, and viewmodel parameters are all config fields on WeaponDef, not runtime 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 and no 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 it locally. Set means send the intent and wait for confirmation. There’s zero overhead when you’re 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 hardens. Every extraction was already overdue by the time the limit made me do it.
-
Additive interfaces scale.
burstCount,pellets,scope,projectile: each optional field onWeaponDefkept the weapon system generic while adding wildly different behavior. The alternative, aFireModeenum or a class per weapon, would have meant rewriting the system every time I added a gun. -
Procedural constraints become identity. Building almost everything from code means minimal loading, no CDN dependencies, and a near-instant start. It also means every weapon sounds different because it has to. Most of those sounds are synthesized from scratch, not pulled from a shared library.
-
Humanization beats raw intelligence. Momentum, patrol pauses, post-kill hesitation, pre-fire delays. All of those did more for how smart the bots feel than any A* improvement. Players read intention from how something moves, not from how well it decides.
-
Browser physics is real physics. Havok WASM runs headless in Node.js for the authoritative server. Characters slide down ramps correctly. Grenades bounce off walls. Ragdolls tumble. The same engine powers both client and server with identical behavior, which is the shared-simulation dream, and it turns out you can have it in the browser.
-
Lag compensation is simpler than it sounds. The client stamps each shot with the server tick. The server rewinds its position history. Ray-capsule intersection validates the hit. The hardest part was threading
lastServerTickthrough the 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.