threejs-game
Three.js Game Development
You are an expert Three.js game developer. Follow these opinionated patterns when building 3D browser games.
Reference: See
reference/llms.txt(quick guide) andreference/llms-full.txt(full API + TSL) for official Three.js LLM documentation. Prefer patterns from those files when they conflict with this skill.
Performance Notes
- Take your time with each step. Quality is more important than speed.
- Do not skip validation steps — they catch issues early.
- Read the full context of each file before making changes.
- Profile before optimizing. The bottleneck is rarely where you think.
Reference Files
For detailed reference, see companion files in this directory:
core-patterns.md— Full EventBus, GameState, Constants, and Game.js orchestrator codetsl-guide.md— Three.js Shading Language reference (NodeMaterial classes, when to use TSL)input-patterns.md— Gyroscope input, virtual joystick, unified analog InputSystem, input priority system
Tech Stack
- Renderer: Three.js (
three@0.183.0+, ESM imports) - Build Tool: Vite
- Language: JavaScript (not TypeScript) for game templates — TypeScript optional
- Package Manager: npm
Project Setup
When scaffolding a new Three.js game:
mkdir <game-name> && cd <game-name>
npm init -y
npm install three@^0.183.0
npm install -D vite
Create vite.config.js:
import { defineConfig } from 'vite';
export default defineConfig({
root: '.',
publicDir: 'public',
server: { port: 3000, open: true },
build: { outDir: 'dist' },
});
Add to package.json scripts:
{
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}
Modern Import Patterns
Vite / npm (default — used in our templates)
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
Import Maps / CDN (standalone HTML games, no build step)
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
</script>
Use import maps when shipping a single HTML file with no build tooling. Pin the version in the import map URL.
Required Architecture
Every Three.js game MUST use this directory structure:
src/
├── core/
│ ├── Game.js # Main orchestrator - init systems, render loop
│ ├── EventBus.js # Singleton pub/sub for all module communication
│ ├── GameState.js # Centralized state singleton
│ └── Constants.js # ALL config values, balance numbers, asset paths
├── systems/ # Low-level engine systems
│ ├── InputSystem.js # Keyboard/mouse/gamepad input
│ ├── PhysicsSystem.js # Collision detection
│ └── ... # Audio, particles, etc.
├── gameplay/ # Game mechanics
│ └── ... # Player, enemies, weapons, etc.
├── level/ # Level/world building
│ ├── LevelBuilder.js # Constructs the game world
│ └── AssetLoader.js # Loads models, textures, audio
├── ui/ # User interface
│ └── ... # Game over, overlays
└── main.js # Entry point - creates Game instance
Core Principles
- Core loop first — Implement one camera, one scene, one gameplay loop. Add player input and a terminal condition (win/lose) before adding visual polish. Keep initial scope small: 1 mechanic, 1 fail condition, 1 scoring system.
- Gameplay clarity > visual complexity — Treat 3D as a style choice, not a complexity mandate. A readable game with simple materials beats a visually complex but confusing one.
- Restart-safe — Gameplay must be fully restart-safe.
GameState.reset()must restore a clean slate. Dispose geometries/materials/textures on cleanup. No stale references or leaked listeners across restarts.
Core Patterns (Non-Negotiable)
Every Three.js game requires these four core modules. Full implementation code is in core-patterns.md.
1. EventBus Singleton
ALL inter-module communication goes through an EventBus (core/EventBus.js). Modules never import each other directly for communication. Provides on, once, off, emit, and clear methods. Events use domain:action naming (e.g., player:hit, game:over). See core-patterns.md for the full implementation.
2. Centralized GameState
One singleton (core/GameState.js) holds ALL game state. Systems read from it, events update it. Must include a reset() method that restores a clean slate for restarts. See core-patterns.md for the full implementation.
3. Constants File
Every magic number, balance value, asset path, and configuration goes in core/Constants.js. Never hardcode values in game logic. Organize by domain: PLAYER_CONFIG, ENEMY_CONFIG, WORLD, CAMERA, COLORS, ASSET_PATHS. See core-patterns.md for the full implementation.
4. Game.js Orchestrator
The Game class (core/Game.js) initializes everything and runs the render loop. Uses renderer.setAnimationLoop() -- the official Three.js pattern (handles WebGPU async correctly and pauses when the tab is hidden). Sets up renderer, scene, camera, systems, UI, and event listeners in init(). See core-patterns.md for the full implementation.
Renderer Selection
WebGLRenderer (default — use for all game templates)
Maximum browser compatibility. Well-established, most examples and tutorials use this. Our templates default to WebGLRenderer.
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
WebGPURenderer (when you need TSL or compute shaders)
Required for custom node-based materials (TSL), compute shaders, and advanced rendering. Note: import path changes to 'three/webgpu' and init is async.
import * as THREE from 'three/webgpu';
const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init();
When to pick WebGPU: You need TSL custom shaders, compute shaders, or node-based materials. Otherwise, stick with WebGL. See tsl-guide.md for TSL details.
Play.fun Safe Zone
The Play.fun SDK renders a 75px fixed iframe at top: 0; z-index: 9999. All HTML overlay UI (game-over screens, menus, buttons, text) must account for this.
Constants
// In Constants.js
export const SAFE_ZONE = {
TOP_PX: 75, // pixels — use for CSS/HTML overlays
TOP_PERCENT: 8, // percent of viewport height
};
CSS Rule
All .overlay elements (game-over, pause, menus) must include padding to avoid the widget:
.overlay {
padding-top: max(20px, 8vh); /* Safe zone for Play.fun widget bar */
}
What to Check
- No text, buttons, or interactive elements in the top ~75px of the viewport
- Game-over overlays center content in the usable area (below the widget), not the full viewport
- Score displays, titles, and restart buttons are all visible and not hidden behind the widget
Note: The 3D canvas itself renders behind the widget, which is fine — only HTML overlay UI needs the safe zone offset. In-world 3D elements (HUD textures, floating text) should avoid the top 8% of screen space.
Performance Rules
- Use
renderer.setAnimationLoop()instead of manualrequestAnimationFrame. It pauses when the tab is hidden and handles WebGPU async correctly. - Cap delta time:
Math.min(clock.getDelta(), 0.1)to prevent death spirals - Cap pixel ratio:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))— avoids GPU overload on high-DPI screens - Object pooling: Reuse
Vector3,Box3, temp objects in hot loops to minimize GC. Avoid per-frame allocations — preallocate and reuse. - Disable shadows on first pass — Only enable shadow maps when specifically needed and tested on mobile. Dynamic shadows are the single most expensive rendering feature.
- Keep draw calls low — Fewer unique materials and geometries = fewer draw calls. Merge static geometry where possible. Use instanced meshes for repeated objects.
- Prefer simple materials — Use
MeshBasicMaterialorMeshStandardMaterial. AvoidMeshPhysicalMaterial, custom shaders, or complex material setups unless specifically needed. - No postprocessing by default — Skip bloom, SSAO, motion blur, and other postprocessing passes on first implementation. These tank mobile performance. Add only after gameplay is solid and perf budget allows.
- Keep geometry/material count small — A game with 10 unique materials renders faster than one with 100. Reuse materials across objects with the same appearance.
- Use
powerPreference: 'high-performance'on the renderer - Dispose properly: Call
.dispose()on geometries, materials, textures when removing objects - Frustum culling: Let Three.js handle it (enabled by default) but set bounding spheres on custom geometry
Asset Loading
- Place static assets in
/public/for Vite - Use GLB format for 3D models (smaller, single file)
- Use
THREE.TextureLoader,GLTFLoaderfromthree/addons - Show loading progress via callbacks to UI
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
function loadModel(path) {
return new Promise((resolve, reject) => {
loader.load(
path,
(gltf) => resolve(gltf.scene),
undefined,
(error) => reject(error),
);
});
}
Input Handling (Mobile-First)
All games MUST work on desktop AND mobile unless explicitly specified otherwise. Allocate 60% effort to mobile / 40% desktop when making tradeoffs. Choose the best mobile input for each game concept:
| Game Type | Primary Mobile Input | Fallback |
|---|---|---|
| Marble/tilt/balance | Gyroscope (DeviceOrientation) | Virtual joystick |
| Runner/endless | Tap zones (left/right half) | Swipe gestures |
| Puzzle/turn-based | Tap targets (44px min) | Drag & drop |
| Shooter/aim | Virtual joystick + tap-to-fire | Dual joysticks |
| Platformer | Virtual D-pad + jump button | Tilt for movement |
Unified Analog InputSystem
Use a dedicated InputSystem that merges keyboard, gyroscope, and touch into a single analog interface. Game logic reads moveX/moveZ (-1..1) and never knows the source. Keyboard input is always active as an override; on mobile, the system initializes gyroscope (with iOS 13+ permission request) or falls back to a virtual joystick. See input-patterns.md for the full implementation, including GyroscopeInput, VirtualJoystick, and input priority patterns.
When Adding Features
- Create a new module in the appropriate
src/subdirectory - Define new events in
EventBus.jsEvents object usingdomain:actionnaming - Add configuration to
Constants.js - Add state to
GameState.jsif needed - Wire it up in
Game.jsorchestrator - Communicate with other systems ONLY through EventBus
Pre-Ship Validation Checklist
Before considering a game complete, verify:
- Core loop works — Player can start, play, lose/win, and see the result
- Restart works cleanly —
GameState.reset()restores a clean slate, all Three.js resources disposed - Touch + keyboard input — Game works on mobile (gyro/joystick/tap) and desktop (keyboard/mouse)
- Responsive canvas — Renderer resizes on window resize, camera aspect updated
- All values in Constants — Zero hardcoded magic numbers in game logic
- EventBus only — No direct cross-module imports for communication
- Resource cleanup — Geometries, materials, textures disposed when removed from scene
- No postprocessing — Unless explicitly needed and tested on mobile
- Shadows disabled — Unless explicitly needed and budget allows
- Delta-capped movement —
Math.min(clock.getDelta(), 0.1)on every frame - Mute toggle — Audio can be muted/unmuted;
isMutedstate is respected - Safe zone respected — All HTML overlay UI has
padding-top: max(20px, 8vh)for Play.fun widget (75px at top) - Build passes —
npm run buildsucceeds with no errors - No console errors — Game runs without uncaught exceptions or WebGL failures