Files

14 KiB
Raw Permalink Blame History

Browser Game Best Practices — Three.js / Babylon.js / Phaser (controls, camera, orientation, loop, assets, perf)

Consolidated from official docs, well-regarded tutorials, and engine forums (see Sources at bottom). Focus: what an AI builder needs to get browser games that feel right and don't have the usual bugs.


1. Game loop & timing (the #1 correctness issue)

Rules:

  • Drive the loop with renderer.setAnimationLoop(fn) in Three.js (internally uses requestAnimationFrame, plays nicely with WebXR) or the engine's built-in loop (Phaser update(time, delta), Babylon scene.onBeforeRenderObservable / engine.runRenderLoop). Avoid setTimeout/setInterval and Date.now().
  • Scale ALL movement/animation by delta time (seconds since last frame) so speed is frame-rate independent (30fps laptop vs 144Hz monitor). e.g. mesh.position.x += speed * delta.
  • Cap delta to avoid huge jumps after a backgrounded tab: delta = Math.min(delta, 0.1).
  • Compute delta exactly once per frame and reuse it everywhere.
    • Three.js gotcha: THREE.Clock.getDelta() mutates state — calling it more than once per frame returns ~0 on later calls (very common bug). Newer THREE.Timer (Clock deprecated ~r183) has an explicit .update() so delta can be read multiple times safely.
  • For physics/gameplay stability use a fixed timestep + accumulator ("Fix Your Timestep"): run fixedUpdate(FIXED_STEP) (e.g. 1/60) in a while (accumulator >= FIXED_STEP) loop, render at display rate, optionally interpolate. Variable delta is fine for purely visual demos.
  • Modularize: give objects a .update(delta)/.tick(delta) method and iterate an "updatables" list; keep the top-level loop lean.
  • On-demand rendering: if nothing is animating, stop the loop / render only on change to save battery (Three.js setAnimationLoop(null); R3F frameloop="demand").

Reference pattern (Three.js, Timer + capped delta + fixed step):

const timer = new THREE.Timer();
let accumulator = 0; const FIXED = 1/60;
function animate() {
  timer.update();
  let delta = Math.min(timer.getDelta(), 0.25);
  accumulator += delta;
  while (accumulator >= FIXED) { fixedUpdate(FIXED); accumulator -= FIXED; } // physics/AI
  updateVisuals(delta);
  renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);

2. First-person / WASD + mouse-look controls (Three.js)

The canonical reference is the official misc_controls_pointerlock example (https://threejs.org/examples/misc_controls_pointerlock.html).

Key facts & rules:

  • PointerLockControls only handles the Pointer Lock API + mouse-look (yaw/pitch). It does NOT include WASD movement — you implement keyboard + translation yourself.
  • Add controls.getObject() (the yaw container) to the scene, not the camera directly; the camera is a child for pitch.
  • Track keys with boolean state flags set in keydown/keyup (support both KeyW/ArrowUp, etc.). Do NOT move on the keydown event itself.
  • Move with velocity + delta time (acceleration + friction/damping), not a fixed distance per press — feels far better.
  • Use controls.moveForward() / controls.moveRight() — they respect current yaw (local XZ plane). Do NOT modify camera.rotation/camera.position directly for horizontal movement.
  • Pointer lock requires a user gesture: always show a "Click to play" blocker overlay; call controls.lock() on click; handle 'lock'/'unlock' events and pointerlockerror; provide escape-to-unlock and a crosshair.
  • Avoid the deprecated FirstPersonControls (behaves like fly controls).
  • For real collisions/slopes/stairs, integrate a physics character controller: Rapier.js KinematicCharacterController (currently favored), or cannon-es (PointerLockControlsCannon), Ammo.js. Sync the body to controls.getObject().
  • Encapsulate as a FirstPersonController class with update(delta), lock(), dispose(), and tunable speed/jumpHeight. Add sprint (Shift), crouch, head-bob for feel.
  • R3F: use drei <PointerLockControls /> + useKeyboardControls + useFrame.

Other camera rigs:

  • Orbit / product inspection: Three.js OrbitControls; Babylon ArcRotateCamera (set lower/upperRadiusLimit, lower/upperBetaLimit, inertia, panningSensibility).
  • Third-person follow: lerp camera toward a target offset behind the player each frame; camera.lookAt(player); Babylon has FollowCamera.
  • Keep the far plane / camera.maxZ (Babylon) as small as practical — improves culling, overdraw, and depth precision.

3. 3D orientation & coordinate conventions (a top source of "why is my model sideways/backwards" bugs)

  • Three.js is right-handed, +Y up: +X right, +Y up, +Z toward the viewer. Default camera sits at +Z looking toward Z. (Differs from Unreal's Z-up, etc.) Changing global up (Object3D.DefaultUp) is discouraged — it affects helpers/grids.
  • Forward-axis gotcha:
    • Most Object3D (Mesh/Group): local forward is +Z (0,0,1). Model/rotate so the "front" aligns with +Z.
    • Camera (and some lights): forward is local Z (0,0,-1). This is the classic "camera looks backwards" gotcha.
  • Many primitives (ConeGeometry, CylinderGeometry) point along +Y by default → rotate before use, e.g. geo.rotateX(Math.PI/2) to make +Y → +Z (align an arrow/cone's tip with forward).
  • object.lookAt(target) rotates in world space so the forward axis points at the target (uses .up, quaternions internally; does not move the object). For nested targets, first target.getWorldPosition(v). To face a direction vector: obj.lookAt(obj.position.clone().add(dir)).
  • Imported glTF/GLB models may need an initial rotation to match +Z-forward. glTF/Blender export is right-handed Y-up, consistent with Three.js.
  • Debug with AxesHelper and ArrowHelper to visualize orientation quickly.

4. Performance (Three.js-centric, principles apply broadly)

The #1 killer is draw calls (renderer.info.render.calls). Target <100/frame for smooth 60fps on most hardware; 500+ stutters; mobile is stricter.

  • Share materials aggressively (reuse one material instance across meshes).
  • Instancing (InstancedMesh) for many identical objects (trees, crowds, bullets) → one draw call; update via setMatrixAt + instanceMatrix.needsUpdate. Note: default frustum culling applies to the whole instanced mesh.
  • BatchedMesh (r156+) for varied geometries sharing one material, with per-object visibility + perObjectFrustumCulled.
  • Merge geometries (BufferGeometryUtils.mergeGeometries) for fully static scenery sharing a material (loses per-object culling — one bounding volume).
  • Frustum culling is automatic (mesh.frustumCulled); after modifying geometry call computeBoundingBox()/computeBoundingSphere(). Reduce camera.far where possible.
  • Dispose GPU resources manually — Three.js does NOT GC them. On removing objects/switching levels: geometry.dispose(), material.dispose(), and dispose all textures. Watch renderer.info.memory. Use object pooling for particles/enemies instead of create/destroy churn.
  • Avoid allocations in the hot loop (no new Vector3()/new Matrix4() per frame; reuse temporaries; Babylon has TmpVectors and xxxToRef()).
  • LOD (THREE.LOD) — swap lower-poly at distance. Compress geometry (Draco) and textures (KTX2/Basis); prefer texture atlases.
  • Limit lights; bake lighting/AO where possible; keep shadow maps small (and RENDER_ONCE refresh for static lights).
  • Add Stats.js/FPS counter during dev; profile with Spector.js / engine inspector; profile on target devices.

Babylon.js specifics: prefer WebGPU w/ fallback + powerPreference:"high-performance"; scene.performancePriority (Intermediate/Aggressive); scene.autoClear=false when meshes cover viewport; scene.skipPointerMovePicking=true if no hover-picking; thin instances for static crowds; mesh.freezeWorldMatrix(), material.freeze(), scene.freezeActiveMeshes() for static content; engine.setHardwareScalingLevel(2) on low-end/mobile; SceneOptimizer to auto-degrade quality to hit a target FPS; keep camera.maxZ low. Camera "Behaviors" (Framing/Bouncing/AutoRotation) give polish for free.

Phaser specifics: heavy object pooling (Groups with maxSize, recycle via active/visible=false), process only active objects, texture atlases (Texture Packer), destroy tweens/emitters on SHUTDOWN, smaller canvas + CSS upscaling, lazy-load per-level assets.


5. Engine / stack choice

  • 2D games → Phaser (Phaser 3/4): scenes, Arcade/Matter physics, Scale Manager, huge ecosystem, great mobile support. Best default for 2D.
  • 3D games → Three.js (most control, largest community) or Babylon.js (batteries-included: inspector, physics, WebGPU, camera behaviors, SceneOptimizer). PlayCanvas if you want a full editor/engine.
  • Physics: 2D → Arcade (simple) / Matter (rigid bodies); 3D → Rapier.js (fast, WASM, popular) / cannon-es / Ammo.js.
  • Tooling: Vite + TypeScript; PWA (manifest + service worker) for installable/offline mobile.

Phaser architecture (from best-practice write-ups): one file per Scene extending Phaser.Scene; a scene stack of Boot → Preload (progress bar) → Menu → Game → parallel UI/HUD (scene.launch) → Pause/GameOver. Lifecycle: init(data)preload()create(data)update(time,delta); clean up on SHUTDOWN. Constants file for all string keys. Decouple scenes via an EventEmitter "EventsCenter" or registry, not direct references. Prefabs (custom GameObject subclasses) + FSM/ECS for complex logic instead of a giant update().


  • Generate real images for sprites/textures/backgrounds/UI where the builder can (matches Lovable/v0 philosophy: no placeholder images in the final product). For pixel art request specific specs ("clean 32×32 side-view run cycle, 8 frames, sprite sheet, transparent background").
  • Don't hand-draw complex SVG/geometry for illustrations or maps — use real assets or a library (echoes v0/Claude anti-slop rules).
  • Optimize for the browser: compressed textures (KTX2/Basis/WebP), power-of-two, atlases, keep total download small (aim <2050MB). Draco/Meshopt for glTF.
  • Placeholder > bad attempt (Claude): if a good asset can't be produced, use a clean placeholder rather than an ugly hand-rolled one.
  • Set crossOrigin='anonymous' on images drawn to <canvas>/textures to avoid CORS taint (v0 rule).
  • 3D model pipeline: glTF/GLB (Y-up, right-handed) with Draco/Meshopt/KTX2; verify forward-axis orientation on import.

7. Mobile / responsive / touch

  • Distinguish the canvas drawing buffer (canvas.width/height) from CSS display size; account for devicePixelRatio to avoid blurry/tiny output. Rendering at a smaller internal resolution + CSS upscale is a valid perf win; image-rendering: pixelated for pixel art.
  • Choose a base design resolution + aspect ratio; letterbox (FIT) to avoid stretching. Phaser: scale: { mode: Phaser.Scale.FIT, autoCenter: CENTER_BOTH, width, height } and handle the resize event; listen for orientationchange.
  • Viewport meta: width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no; CSS touch-action: none and preventDefault() on touch to stop scroll/zoom.
  • Touch controls: virtual joystick (e.g. nipplejs, mode:'static' or 'dynamic'), split screen (left = move, right = actions/aim), normalize input to 1..1 and apply with delta time, visual feedback on active. Minimum touch/hit target 44px (Claude/Apple HIG). Unify keyboard + touch + Gamepad API into one input state.
  • Test on real iOS Safari (audio unlock on gesture, WebGL context loss, touch coalescing) and low-end Android.

8. Common browser-game bugs to prevent (checklist)

  • Movement tied to frame rate (no delta) → runs too fast/slow on different displays.
  • Clock.getDelta() called multiple times per frame → things freeze/jitter.
  • Model faces wrong way → forgot +Z-forward convention / camera Z; fix geometry rotation or lookAt.
  • Pointer lock never engages → missing user-gesture click / blocker overlay.
  • Memory leak / crash after level reload → geometries/materials/textures not disposed.
  • Frame drops → too many draw calls (needs instancing/batching/atlases), per-frame allocations, uncompressed textures.
  • Blurry or misaligned on mobile/retina → not handling devicePixelRatio / canvas resize.
  • CORS-tainted canvas → missing crossOrigin='anonymous'.
  • Physics tunneling/instability → variable timestep instead of fixed step.
  • Audio doesn't play on mobile → not unlocked on first user gesture.

Sources