Game engine template · 2026
FP Engine
A reusable first-person game engine. Slide, mantle, bunny hop, and procedural audio, all tuned for feel.

FP Engine — Case Study
A reusable first-person game engine template built with Babylon.js 8.0, TypeScript, and Vite — built session by session, from bare scene to a full movement sandbox.
Try it live → firstpersonengine.com
The Starting Point
This project began as a question: what if the Three.js portfolio gallery I already built became the seed of something reusable?
The gallery at gregdesciscio.com proved that first-person web experiences are genuinely compelling. But Three.js required hand-rolling everything — physics, character controllers, collision response, audio routing. Every interesting interaction meant writing infrastructure first. Babylon.js 8.0 ships Havok physics, a capsule character controller, spatial audio, and a full post-processing pipeline out of the box, all TypeScript-first. The core movement feel, procedural audio, and UI patterns from the Three.js build were worth keeping. Everything else would be written fresh.
The goal: a clean, extensible template that any first-person game could start from.
The Build
Day One: Foundation to Playable
The first session produced 34 TypeScript files across 11 modules — a playable prototype with Havok physics, multi-provider input (keyboard+mouse, gamepad, touch), procedural footstep audio, and a post-processing pipeline. Two architectural decisions made from the start shaped everything that followed:
ISystem interface. Every game system — physics, audio, input, rendering — implements update(dt) and dispose(). The game loop iterates a list of systems without knowing their types. Adding or removing a system never touches the loop.
IInputProvider pattern. Keyboard, gamepad, and touch are interchangeable providers that write into a shared InputState snapshot. PlayerController reads that snapshot; it doesn’t know or care which device produced it. This paid off immediately when mobile controls needed different sensitivity and sprint behavior — zero changes to the movement code.
The first real lesson from Babylon.js was about side-effect imports. Tree-shaking removes Scene prototype methods unless you import the module that patches them. scene.enablePhysics() needs joinedPhysicsEngineComponent.js. scene.pickWithRay() needs ray.js. TypeScript won’t catch these — they’re runtime-only failures. Building a lookup pattern for missing methods (search .d.ts files) became a project standard early.
Movement Feel
Raw WASD movement works. Movement that feels good takes deliberate work. Seven techniques were added across two sessions:
- Asymmetric damping — lower deceleration than acceleration. Stops are snappier than starts. This single change has the largest perceptible effect on feel.
- Coyote time — 100ms grace period after leaving a ledge where jump still fires. Prevents the frustration of “I definitely pressed jump.”
- Jump buffering — 80ms pre-land input window. The jump fires the moment you touch ground, not when you press early.
- Variable jump height — hold for full height, release early for a short hop. The Celeste/Hollow Knight approach.
- Landing dip — transient camera offset proportional to impact speed. Separate from head bob (different timescale, different concern).
- Sprint FOV — smooth exponential lerp between walk and sprint field-of-view.
- Momentum grace window — on landing, air damping runs for 150ms before ground damping kicks in. Well-timed consecutive jumps preserve speed. The seed of the bhop system.
The Slide System
Sprint + crouch triggers a momentum slide. Simple to describe; complicated to get right.
The slide has six configurable parameters (entry speed, friction decay, max duration, slope interaction, slide-jump boost, camera roll) and a dedicated SlideState helper class that was extracted when PlayerController grew past the 200-line module limit. The helper owns its own lifecycle — cooldown, speed decay, direction locking — while PlayerController handles camera effects and integration with other systems.
Slope detection was the hard part. Character controllers on downhill slopes are fundamentally unreliable: the player “floats” above the surface because horizontal movement carries the capsule past the slope faster than gravity pulls it down. checkSupport, grounded, and surfaceNormal all flicker. The fix was a slope grace timer — cache the last valid surface normal for 250ms and use it for the downhill direction check. The same pattern as coyote time, solving the same class of problem: a boolean that should be true but flickers false due to physics tick timing.
Camera roll during a slide revealed a subtler issue: the roll was always positive, always tilting right, regardless of slide direction. Once the player turns their head mid-slide, a fixed-sign tilt becomes semantically disconnected from the motion. The fix was a 2D cross product in _syncCamera — the cross product of camera forward and slide direction gives a signed value that naturally tracks which side of the view the motion is on, in any orientation, with no extra state.
Bunny Hop Chains
Slide-jump sets a _bhopChainPending flag. On landing with crouch+forward held, a new slide starts immediately, bypassing cooldown, at decayed speed. Chain up to twice before the system resets. Simple description; four bugs found during implementation:
- Landing overwrote the slide-jump’s extended momentum timer. Fixed with
Math.max(). - Holding crouch during the jump arc set target velocity to crouch speed (1.5), draining 29% of slide-jump speed by the time you landed. Fixed by flooring target to sprint speed while
_bhopChainPending. - The 1.3x first-jump boost applied on every chain, letting speed grow faster than decay removed it. Chain jumps now use 1.0x — pure carry.
- The momentum speed floor triggered on every landing, not just bhop landings, letting walk+jump spam accelerate to sprint speed. Scoped to
_bhopChainPendingonly.
Tuning the bhop cap (max 2 chains, 15% decay per chain, slope boost suppressed during chains) was a balance between expressive movement and exploitation. The current values let skilled play feel fast without creating a trivial infinite speed loop.
Mantle System
Jump toward a ledge while moving forward and the player vaults onto it. Three-ray detection: forward ray finds the wall, downward ray finds the ledge surface, upward ray checks for ceiling clearance. Height range 0.8–2.6m. On success, a quadratic bezier arc with smoothstep easing carries the player from current position to standing on the ledge.
Two lessons from the first implementation:
Capsule center is a terrible height reference. The center rises ~1m during a jump, shrinking the valid detection window to 1–2 frames. Feet position is far more stable — switching to feetY = capsulePos.y - capsuleHeight/2 gave a wide multi-frame detection window throughout the arc.
Forward ray is an assumption. Sloped geometry, ramps, and thin rotated objects have no vertical wall face for the forward ray to hit. A fallback “down-first probe” at a fixed reach distance is geometry-agnostic and catches everything the forward ray misses.
Camera effects during mantle went through one failed iteration. Additive pitch and roll offsets caused persistent feel issues after the animation — Babylon.js Euler angle handling produced unexpected interactions when roll returned to zero. Stripped to position-only: eye dip (camera lowers during the grab phase) and FOV squeeze (slight narrowing during the pull-over). Both are reliable, don’t interact with the angle system, and together make the mantle feel physically distinct from a jump.
The mantle boost came later: hold crouch when a mantle completes (or tap jump mid-arc on mobile) and you launch into a mini slide burst on the ledge. The slide system already handled this. SlideState.forceStart() already existed for bhop chains — adding an optional duration parameter was the only change needed.
Mobile Controls
Mobile had three problems that compounded each other:
TouchProviderwas never registered inGame.ts. The class existed, was correct (mostly), and was never plugged in.- Mobile browsers don’t support
requestPointerLock().PlayerControllerreturned early when!pointerLock.locked, so nothing ran on mobile. - Touch look sensitivity was inverted — dividing by 0.004 amplified deltas 250×.
After fixing the fundamentals, two more issues surfaced: boolean input quantization (the joystick’s continuous -1 to +1 values were crushed to on/off with a threshold, causing direction snapping) and no sprint on mobile. Both were solved cleanly through the existing provider pattern — analog axes added to InputState, auto-sprint at 85% joystick deflection added to TouchProvider. Neither change touched PlayerController.
Touch look smoothing was a separate problem: touchmove events fire at irregular intervals, so some frames got zero delta and the next got a large chunk, snapping the camera. Fractional delta consumption (consume 55% of the smoothing buffer per frame) spreads a single event’s delta across ~3 frames with ~50ms of lag — imperceptible on touch.
Mobile performance needed a dedicated preset: SSAO disabled (biggest win), bloom kernel halved, pixel ratio capped at 1.5×. Detected once at init, applied as frozen config variants via spread syntax. No runtime adaptation, no complexity.
Architecture Snapshot
Game.ts (orchestrator)
├── EngineManager → Babylon Engine + resize
├── SceneManager → Scene lifecycle
├── PhysicsSystem → Havok WASM init
├── CharacterController → Capsule physics (gravity, jump, ground detection)
├── FirstPersonCamera → UniversalCamera (inputs cleared)
├── InputSystem → Aggregates providers
│ ├── KeyboardMouseProvider
│ ├── GamepadProvider
│ └── TouchProvider
├── PlayerController → Input → velocity → physics → camera
│ ├── SlideState → Slide mechanics (direction, speed, cooldown)
│ ├── MantleState → Ledge detection + bezier arc
│ └── MantleCameraFX → Eye dip + FOV squeeze during mantle
├── HeadBob → Sinusoidal offset from speed
├── AudioSystem → Web Audio context + unlock
├── ProceduralFootsteps → Synthesis + reverb (bob-synced)
├── ProceduralSlide → Looping friction-scrape sound
├── PostProcessingSystem → DefaultPipeline + SSAO2
├── InteractionSystem → Per-frame raycast, E-key callbacks, mesh outline
├── TutorialSystem → Zone-gated step progression
└── LoadingScreen → HTML/CSS overlay
70 TypeScript files across 13 modules. ~6,100 lines. All files under 200 lines.
What Carried Over from Three.js
| System | Original (Three.js) | New (Babylon.js) | What Changed |
|---|---|---|---|
| Movement feel | Velocity lerp + damping | Same math, feeds physics controller | Physics handles collision instead of raycasts |
| Head bob | Sinusoidal Y offset | Same algorithm | Now drives footstep triggers |
| Footsteps | Filtered noise + convolver | Same synthesis pipeline | Triggers from bob phase zero-crossings |
| Touch controls | Split-zone joystick + look | Same zones, IInputProvider interface | Pluggable, not hard-wired |
| Loading screen | HTML/CSS overlay | Same pattern | Simpler — no portfolio content |
| Post-processing | Three.js EffectComposer | Babylon DefaultRenderingPipeline | Built-in pipeline replaces custom shaders |
Key Technical Decisions
Raw Web Audio API, not Babylon’s audio engine. More control over synthesis. Footsteps are procedural filtered noise (not files), tuned per-frame via AudioParam. The slide is a looping lowpass-filtered noise buffer with speed-modulated filter cutoff. Shared reverb routing was extracted to a utility when two audio systems duplicated the same IR generation and dry/wet gain setup.
HTML/CSS overlays for all UI. Renders before the engine is ready, zero 3D overhead, easy to style and restyle. The loading screen, tutorial, interaction prompts, and pause menu are all DOM — not 3D text or in-world geometry.
Object.freeze() on all configs. Games pass new frozen config objects to system constructors. No runtime mutation of tuning values. Config variants use spread syntax: { ...BASE_CONFIG, ...overrides }.
checkSupportToRef() over checkSupport(). The convenience method allocates 4 objects internally per call. At 60fps this was the only significant hot-path allocation in the render loop. Pre-allocated CharacterSurfaceInfo at module scope eliminates it.
Guard flags need fallback expiration. The ramp edge bug — player permanently unable to jump after clipping geometry — came from _pendingJump having a single clearing condition (!supported) that assumed physics would report unsupported after a jump. Near geometry, Havok reports continuous support through the entire jump arc. Second clearing condition: verticalVelocity <= 0. The flag expires when the jump impulse is spent, regardless of surface state.
Current Feature Set
Movement: Walk, sprint, crouch, crouch-slide with slope interaction, slide-jump, bunny hop chains (max 2), ledge mantle with bezier arc, mantle boost-slide, moving platform riding.
Audio: Procedural footsteps (bob-synced, speed-scaled, crouch-softened), procedural slide friction (looping, speed-modulated filter), spatial audio via Web Audio API.
Input: Keyboard+mouse, gamepad (analog movement, toggle sprint, L3 sprint), touch (analog joystick, tap-to-jump, slide button, auto-sprint at full deflection).
Mobile: Dedicated post-processing preset (SSAO off, reduced bloom), 1.5× pixel ratio cap, touch look smoothing, pointer-lock bypass.
World: Interaction system (raycast, E-key callbacks, mesh outline), tutorial system (zone-gated progression, 8-step obstacle course), moving platforms.
Engine: ISystem loop, frozen config layer, dev Inspector (dynamic import, backtick toggle), static mesh optimization (freezeWorldMatrix, skipPointerMovePicking).
What This Demonstrates
Building a game engine from scratch — even a small one — forces contact with a class of problems that normal application work doesn’t surface: physics tick ordering, input convention mismatches, per-frame allocation budgets, platform-specific APIs, and the compounding interactions between systems that were designed in isolation.
The session structure of this project made those interactions visible. The bhop speed loop required understanding how four separate systems (slide entry, jump boost, momentum grace, airborne velocity flooring) composed. The mantle camera artifacts required understanding Babylon.js’s Euler angle system well enough to know when to stop trying and simplify the approach. The mobile jump-lock bug required debug logging to reveal a failure mode that was completely different from what the code suggested.
The output is a template. Every game that starts from it gets movement that feels tuned, input that works on every device, audio that responds to player state, and a clean module graph to extend.
Gallery
Gallery coming soon
More visuals from this project are on the way.