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 GameObject
    • removeGameObject(gameObject): Remove a GameObject
    • start(): Initialize the scene (calls start on all GameObjects)
    • update(): Update each frame (physics → GameObject updates → rendering)
    • trackLoad(promise): Register a loading Promise
    • waitForLoadingIdle(): 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 GameObject needs visual and physics behavior, so attach visual and physics Components
  • Key methods
    • addComponent(component): Add a Component
    • getComponent(ComponentClass): Get a Component of the specified type
    • start(): 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()
  • Key methods
    • getGameObject(): Get the attached GameObject
    • getComponent(ComponentClass): Get another Component on the same GameObject
    • start(): 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.