npx skills add ...
npx skills add playableintelligence/game-creator --skill threejs-game
Build 3D browser games with Three.js using event-driven modular architecture. Use when creating a new 3D game, adding 3D game features, setting up Three.js scenes, or working on any Three.js game project.
npx skills add playableintelligence/game-creator --skill threejs-game
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.
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 systemFor performance optimization patterns with measured before/after evidence, see the threejs-perf skill (skills/threejs-perf/SKILL.md).
three@0.183.0+, ESM imports)When scaffolding a new Three.js game:
Create vite.config.js:
Add to package.json scripts:
Use import maps when shipping a single HTML file with no build tooling. Pin the version in the import map URL.
Every Three.js game MUST use this directory structure:
GameState.reset() must restore a clean slate. Dispose geometries/materials/textures on cleanup. No stale references or leaked listeners across restarts.Every Three.js game requires these four core modules. Full implementation code is in core-patterns.md.
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.
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.
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.
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.
Maximum browser compatibility. Well-established, most examples and tutorials use this. Our templates default to WebGLRenderer.
Required for custom node-based materials (TSL), compute shaders, and advanced rendering. Note: import path changes to 'three/webgpu' and init is async.
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.
When games run inside the Play.fun dashboard on mobile Safari, the SDK sets CSS custom properties on the game iframe's document.documentElement:
--ogp-safe-top-inset — space below the Play.fun header bubbles (~68px on mobile)--ogp-safe-bottom-inset — space above Safari bottom controls (~148px on mobile)Both default to 0px when not running inside the dashboard (desktop, standalone).
All .overlay elements (game-over, pause, menus) must use the CSS variables for padding:
Bottom-positioned UI (joysticks, action buttons) must also respect the bottom inset:
Note: The 3D canvas itself renders behind the chrome, which is fine — the game should bleed to fill the full viewport. Only HTML overlay UI needs the safe zone offset. In-world 3D elements (HUD textures, floating text) should avoid the top 8% and bottom inset of screen space.
renderer.setAnimationLoop() instead of manual requestAnimationFrame. It pauses when the tab is hidden and handles WebGPU async correctly.Math.min(clock.getDelta(), 0.1) to prevent death spiralsrenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) — avoids GPU overload on high-DPI screensVector3, Box3, temp objects in hot loops to minimize GC. Avoid per-frame allocations — preallocate and reuse.skills/threejs-perf/ for InstancedMesh patterns (~9,000× fewer draw calls, ~57× faster render CPU).MeshBasicMaterial or MeshStandardMaterial. Avoid MeshPhysicalMaterial, custom shaders, or complex material setups unless specifically needed.powerPreference: 'high-performance' on the renderer.dispose() on geometries, materials, textures when removing objects/public/ for ViteTHREE.TextureLoader, GLTFLoader from three/addonsAll 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 |
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.
src/ subdirectoryEventBus.js Events object using domain:action namingConstants.jsGameState.js if neededGame.js orchestratorBefore considering a game complete, verify:
GameState.reset() restores a clean slate, all Three.js resources disposedMath.min(clock.getDelta(), 0.1) on every frameisMuted state is respectedvar(--ogp-safe-top-inset) / var(--ogp-safe-bottom-inset) for Play.fun safe area; bottom controls offset above the bottom insetnpm run build succeeds with no errorsimport { defineConfig } from 'vite';
export default defineConfig({
root: '.',
publicDir: 'public',
server: { port: 3000, open: true },
build: { outDir: 'dist' },
});{
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';<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>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 instanceimport * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });import * as THREE from 'three/webgpu';
const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init();// In Constants.js — reads SDK CSS vars with static fallbacks
function _readSafeInsets() {
const s = getComputedStyle(document.documentElement);
return {
top: parseInt(s.getPropertyValue('--ogp-safe-top-inset')) || 0,
bottom: parseInt(s.getPropertyValue('--ogp-safe-bottom-inset')) || 0,
};
}
const _insets = _readSafeInsets();
export const SAFE_ZONE = {
TOP_PX: Math.max(75, _insets.top),
BOTTOM_PX: _insets.bottom,
TOP_PERCENT: 8,
};.overlay {
padding-top: max(20px, 8vh, var(--ogp-safe-top-inset, 0px));
padding-bottom: var(--ogp-safe-bottom-inset, 0px);
}#joystick-zone {
bottom: max(20px, 3vh, var(--ogp-safe-bottom-inset, 0px));
}
.bottom-hud {
margin-bottom: var(--ogp-safe-bottom-inset, 0px);
}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),
);
});
}