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…

INSTALLATION
npx skills add https://github.com/opusgamelabs/game-creator --skill threejs-game
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

$27

For performance optimization patterns with measured before/after evidence, see the threejs-perf skill (skills/threejs-perf/SKILL.md).

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> &#x26;&#x26; 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

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).

Constants

// 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,

};

CSS Rule

All .overlay elements (game-over, pause, menus) must use the CSS variables for padding:

.overlay {

  padding-top: max(20px, 8vh, var(--ogp-safe-top-inset, 0px));

  padding-bottom: var(--ogp-safe-bottom-inset, 0px);

}

Bottom-positioned UI (joysticks, action buttons) must also respect the bottom inset:

#joystick-zone {

  bottom: max(20px, 3vh, var(--ogp-safe-bottom-inset, 0px));

}

.bottom-hud {

  margin-bottom: var(--ogp-safe-bottom-inset, 0px);

}

What to Check

  • No text, buttons, or interactive elements in the top or bottom inset areas
  • Game-over overlays center content in the usable area (between both insets), not the full viewport
  • Score displays, titles, and restart buttons are all visible and not hidden behind browser chrome
  • Bottom-positioned controls (joysticks, action buttons) are not clipped by Safari bottom bar

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.

Performance Rules

  • **Use renderer.setAnimationLoop()** instead of manual requestAnimationFrame. 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. See skills/threejs-perf/ for InstancedMesh patterns (~9,000× fewer draw calls, ~57× faster render CPU).
  • Prefer simple materials — Use MeshBasicMaterial or MeshStandardMaterial. Avoid MeshPhysicalMaterial, 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, GLTFLoader from three/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 &#x26; 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.js Events object using domain:action naming
  • Add configuration to Constants.js
  • Add state to GameState.js if needed
  • Wire it up in Game.js orchestrator
  • 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 cleanlyGameState.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 movementMath.min(clock.getDelta(), 0.1) on every frame
  • Mute toggle — Audio can be muted/unmuted; isMuted state is respected
  • Safe zone respected — All HTML overlay UI uses var(--ogp-safe-top-inset) / var(--ogp-safe-bottom-inset) for Play.fun safe area; bottom controls offset above the bottom inset
  • Build passesnpm run build succeeds with no errors
  • No console errors — Game runs without uncaught exceptions or WebGL failures
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card