Modular Game Engine with Matter.js Architecture

This article outlines how to design a decoupled, maintainable game engine architecture by cleanly separating the rendering pipeline, gameplay logic, and Matter.js 2D physics. By using a component-based model or Entity-Component-System (ECS) alongside an orchestration loop with a fixed-timestep update, you can prevent tight coupling, simplify unit testing, and allow individual subsystems to be swapped or refactored without breaking core functionality.

Core Architectural Separation

A modular architecture isolates the engine into three distinct domains, each with a single responsibility:

  1. Gameplay Logic (The State Manager): Contains entity definitions, state machines, rules, combat mechanics, and user input handling. It operates entirely on abstract data and remains completely unaware of canvas contexts, sprites, or physics engine constraints.
  2. Physics Layer (Matter.js Wrapper): Manages rigid bodies, collision listeners, constraints, and numerical step integration. It consumes commands from the logic layer (such as forces, impulses, or linear velocity requests) and outputs updated spatial data (positions and angles).
  3. Rendering Layer (The Visuals): A passive consumer of state. Whether using HTML5 Canvas 2D, PixiJS, or WebGL, this layer reads position and orientation data and renders sprites or geometries. It never modifies entity state or triggers physics updates.

Decoupling via Shared Data Components

Directly referencing Matter.js bodies within visual classes or gameplay classes creates spaghetti code. Instead, bind them using unified component containers or pure data objects.

An Entity acts merely as an identifier (e.g., an ID integer) that links these components together.

The Game Loop and Update Pipeline

To maintain deterministic behavior and prevent visual stuttering, separate the physics and logic execution from the display refresh rate. Logic and Matter.js should run on a fixed timestep, while rendering runs on the variable requestAnimationFrame cycle.

       +---------------------------------------------+
       |             Input Subsystem                 |
       +---------------------------------------------+
                              |
                              v
       +---------------------------------------------+
       |            Logic Update (Fixed)             |
       |  - Process input and state changes          |
       |  - Apply desired forces/velocities          |
       +---------------------------------------------+
                              |
                              v
       +---------------------------------------------+
       |         Physics Update (Matter.js)          |
       |  - Matter.Engine.update(engine, fixedDelta) |
       |  - Resolve collisions & constraints         |
       +---------------------------------------------+
                              |
                              v
       +---------------------------------------------+
       |            Synchronization Step             |
       |  - Copy Body.position/angle to Transform    |
       +---------------------------------------------+
                              |
                              v
       +---------------------------------------------+
       |           Render Update (Variable)          |
       |  - Interpolate transforms (optional)        |
       |  - Draw sprites using Transform data        |
       +---------------------------------------------+

1. Process Inputs and Logic

The logic loop reads buffered user inputs, evaluates entity states, and applies intent to the components. If an entity needs to jump, the logic layer does not manipulate pixel coordinates directly; it dispatches an impulse request to the physics component.

2. Step the Physics Engine

Execute Matter.js using a fixed delta time:

Matter.Engine.update(matterEngine, fixedDeltaTime);

Disable Matter.js's built-in DOM runner and built-in renderer (Matter.Render). Running Matter.js headless ensures total control over the execution order and eliminates reliance on DOM manipulation.

3. Synchronize State

After the physics engine finishes its calculation step, an adapter system reads the updated Matter.Body.position and Matter.Body.angle values and copies them into the entity's Transform component.

class PhysicsSyncSystem {
  update(entities) {
    for (const entity of entities) {
      if (entity.physics && entity.transform) {
        entity.transform.x = entity.physics.body.position.x;
        entity.transform.y = entity.physics.body.position.y;
        entity.transform.rotation = entity.physics.body.angle;
      }
    }
  }
}

4. Render the Frame

The rendering system queries all entities with both a Transform and a Render component. It maps the entity's position directly to screen space. For higher fidelity, you can interpolate the visual positions between the previous and current physics ticks using the remaining frame accumulation time.

Handling Collisions Cleanly

Matter.js dispatches collision events (collisionStart, collisionActive, collisionEnd). Do not place game logic inside Matter.js event listeners. Instead, translate raw physics collisions into domain events.

When a collision occurs, read the entity IDs stored in the body.userData property, generate an application-level event (such as BulletHitPlayerEvent), and queue it. The gameplay logic system processes this queue during its next cycle. This guarantees that gameplay actions (like entity destruction or score updates) occur within the logic step, avoiding state corruption in the middle of a Matter.js physics step.