14 KiB
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 usesrequestAnimationFrame, plays nicely with WebXR) or the engine's built-in loop (Phaserupdate(time, delta), Babylonscene.onBeforeRenderObservable/engine.runRenderLoop). AvoidsetTimeout/setIntervalandDate.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). NewerTHREE.Timer(Clock deprecated ~r183) has an explicit.update()so delta can be read multiple times safely.
- Three.js gotcha:
- For physics/gameplay stability use a fixed timestep + accumulator ("Fix Your Timestep"): run
fixedUpdate(FIXED_STEP)(e.g. 1/60) in awhile (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); R3Fframeloop="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:
PointerLockControlsonly 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 bothKeyW/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 modifycamera.rotation/camera.positiondirectly 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 andpointerlockerror; 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 tocontrols.getObject(). - Encapsulate as a
FirstPersonControllerclass withupdate(delta),lock(),dispose(), and tunablespeed/jumpHeight. Add sprint (Shift), crouch, head-bob for feel. - R3F: use drei
<PointerLockControls />+useKeyboardControls+useFrame.
Other camera rigs:
- Orbit / product inspection: Three.js
OrbitControls; BabylonArcRotateCamera(setlower/upperRadiusLimit,lower/upperBetaLimit,inertia,panningSensibility). - Third-person follow: lerp camera toward a target offset behind the player each frame;
camera.lookAt(player); Babylon hasFollowCamera. - 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.
- Most
- 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, firsttarget.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
AxesHelperandArrowHelperto 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 viasetMatrixAt+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 callcomputeBoundingBox()/computeBoundingSphere(). Reducecamera.farwhere possible. - Dispose GPU resources manually — Three.js does NOT GC them. On removing objects/switching levels:
geometry.dispose(),material.dispose(), and dispose all textures. Watchrenderer.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 hasTmpVectorsandxxxToRef()). - 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_ONCErefresh 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().
6. Assets: generate vs. code vs. link
- 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 <20–50MB). 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 fordevicePixelRatioto avoid blurry/tiny output. Rendering at a smaller internal resolution + CSS upscale is a valid perf win;image-rendering: pixelatedfor 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 theresizeevent; listen fororientationchange. - Viewport meta:
width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no; CSStouch-action: noneandpreventDefault()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
- Three.js official: PointerLock example (https://threejs.org/examples/misc_controls_pointerlock.html), docs for
Timer,Object3D.lookAt,InstancedMesh,BatchedMesh(https://threejs.org/docs/). - "Discover three.js" — animation loop / Loop class (https://discoverthreejs.com/book/first-steps/animation-loop/).
- Three.js Discourse (Clock→Timer r183, setAnimationLoop vs RAF, coordinate-system threads) — https://discourse.threejs.org/.
- Three.js perf write-ups: utsubo.com "100 tips" (https://www.utsubo.com/blog/threejs-best-practices-100-tips), threejsroadmap.com draw calls.
- StackOverflow / Medium tutorials on PointerLock WASD and Rapier character controllers (https://medium.com/javascript-alliance/creating-a-first-person-character-controller-in-three-js-5d96534edfd8).
- Babylon.js: official optimization guide (https://doc.babylonjs.com/features/featuresDeepDive/scene/optimize_your_scene), SceneOptimizer, camera input docs; 2025 forum best-practices thread (https://forum.babylonjs.com/t/best-practices-for-optimizing-babylon-js-scenes-not-just-on-lower-end-devices/58688); Babylon 9.0 notes.
- Phaser: docs.phaser.io (Scenes, Arcade Physics), Phaser Discourse best-practices threads, franzeus.medium.com "How I optimized my Phaser 3 action game in 2025".
- Mobile/touch: nipplejs (https://github.com/yoannmoinet/nipplejs), joshmorony.com Phaser scaling guide.
- Fix Your Timestep (Gaffer On Games) — classic fixed-timestep reference.