Building a Unity-like lightweight game engine with three.js + cannon-es
2025-12-16 ・ ~6 min read
Tech used on this site
- three.js
- Purpose: render 3D with WebGL
- cannon-es
- Purpose: run physics simulation
- Works well with three.js and coordinate systems are easy to align
Why I wanted an architecture
When building many small games, writing the same three.js↔physics synchronization repeatedly becomes tedious and inconsistent. I wanted a shared set of rules so every game can follow the same structure.
If there is an architecture, we can mass-produce games by following the same rules—so I built a lightweight engine.
Goals of the lightweight engine
- Not aiming for a Unity-like editor (too expensive to build first)
- Code-first rules and constraints are enough
Main classes in the lightweight engine
The main class structure of the engine is shown below.
GameScene
├─ GameObject[]
│ ├─ Component[]
│ │ ├─ Transform (position, rotation, scale)
│ │ ├─ RigidBody (physics)
│ │ ├─ ThreeDMesh (3D mesh display)
│ │ ├─ GLBModel (GLB model loading)
│ │ ├─ Camera
│ │ └─ Light
│ └─ start() / update() / onRemove()
├─ THREE.Scene
├─ CANNON.World
└─ SceneLoadTracker (load management)
GameScene
- Responsibilities
- Manages a single game
- Manages GameObjects and supports rendering to canvas
- GameScene calls update() on GameObjects
- Adding a GameObject to GameScene makes it part of the game and visible in three.js if needed
- Does not contain game logic
- Create a dedicated GameObject class for any logic
- Key methods
addGameObject(gameObject): Add a GameObjectremoveGameObject(gameObject): Remove a GameObjectstart(): Initialize the scene (calls start on all GameObjects)update(): Update each frame (physics → GameObject updates → rendering)trackLoad(promise): Register a loading PromisewaitForLoadingIdle(): Wait until loading is complete
- Example usage
const scene = new GameScene(); const ball = new Ball(); ball.addComponent(new Transform()); ball.addComponent(new RigidBody(0.3, new CANNON.Sphere(0.15))); scene.addGameObject(ball); scene.start(); scene.startAnimationLoop();
GameObject
- Responsibilities
- A container that groups Components together on GameScene
- For example,
Ball extends GameObjectneeds visual and physics behavior, so attach visual and physics Components
- For example,
- A container that groups Components together on GameScene
- Key methods
addComponent(component): Add a ComponentgetComponent(ComponentClass): Get a Component of the specified typestart(): Initialization (can be overridden)update(deltaTime): Update each frame (can be overridden)onRemove(): Cleanup on removal (can be overridden)
- Example implementation
export class Ball extends GameObject { constructor() { super('Ball'); this.addComponent(new Transform()); this.addComponent(new RigidBody(0.3, new CANNON.Sphere(0.15))); this.addComponent(new ThreeDMesh( new THREE.SphereGeometry(0.15), new THREE.MeshStandardMaterial({ color: 0xff6600 }) )); } start(): void { const transform = this.getComponent(Transform); transform?.setPosition(0, 5, 0); } }
Component (abstract)
- Responsibilities
- Defines one behavior of a GameObject on GameScene
- Example 1: RigidBody
- Physics simulation and rigid body behavior via cannon-es
- Syncs with Transform component to reflect physics results in coordinates
- Example 2: ThreeDMesh
- Renders primitive shapes like cubes and spheres in three.js
- Syncs with Transform component to get position, rotation, and scale
- Example 3: Camera
- Manages the camera that is the origin of three.js rendering
- Syncs with Transform component to get position and rotation
- Example 4: Transform
- Manages position, rotation, and scale in 3D space
- Acts as the "Source of Truth" referenced by other Components
- Set values with
setPosition(),setRotation(),setScale()
- Example 1: RigidBody
- Defines one behavior of a GameObject on GameScene
- Key methods
getGameObject(): Get the attached GameObjectgetComponent(ComponentClass): Get another Component on the same GameObjectstart(): Initialization (can be overridden)update(deltaTime): Update each frame (can be overridden)onRemove(): Cleanup on removal (can be overridden)
- Transform component example
export class Transform extends Component { private position: THREE.Vector3; private rotation: THREE.Euler; private scale: THREE.Vector3; setPosition(x: number, y: number, z: number): void { this.position.set(x, y, z); this.syncToThree(); // Sync with Three.js Object3D } getPosition(): THREE.Vector3 { return this.position.clone(); } }
Takeaways
- When asking AI to prototype a new game, providing existing games built on the same engine as context makes it produce working prototypes extremely quickly.