Implement Undo Redo in a Matter.js Editor

Implementing an undo-redo history stack in a Matter.js editor requires recording discrete state changes without overloading memory or disrupting the physics simulation. This guide covers the architecture needed to capture transformations, manage past and future action stacks, handle body creation and deletion, and apply state restoration safely using Matter.js native methods.

The Architectural Approach: Command vs. Snapshot

There are two primary ways to track history in a 2D editor:

  1. Full Engine Snapshot: Serializing the entire Matter.js world state into JSON on every change. While simple, this approach causes performance bottlenecks and memory spikes in complex scenes.
  2. Delta Command Pattern (Recommended): Storing only the target body reference and the delta (the "before" and "after" state) for specific user actions such as translation, rotation, scaling, or scene graph modifications.

For a responsive physics editor, the delta command pattern is the standard solution.

Defining the State Payload

A body manipulation typically alters transform, velocity, or structural attributes. When capturing state, record only the necessary parameters:

function captureBodyState(body) {
  return {
    position: { x: body.position.x, y: body.position.y },
    angle: body.angle,
    velocity: { x: body.velocity.x, y: body.velocity.y },
    angularVelocity: body.angularVelocity,
    isStatic: body.isStatic
  };
}

The History Stack Manager

The history manager maintains two arrays (undoStack and redoStack) and enforces a maximum history limit to prevent memory leaks.

class HistoryManager {
  constructor(limit = 50) {
    this.undoStack = [];
    this.redoStack = [];
    this.limit = limit;
  }

  execute(command) {
    this.undoStack.push(command);
    if (this.undoStack.length > this.limit) {
      this.undoStack.shift();
    }
    // Clear redo history whenever a new action occurs
    this.redoStack = [];
  }

  undo() {
    if (this.undoStack.length === 0) return;
    const command = this.undoStack.pop();
    command.undo();
    this.redoStack.push(command);
  }

  redo() {
    if (this.redoStack.length === 0) return;
    const command = this.redoStack.pop();
    command.redo();
    this.undoStack.push(command);
  }
}

Creating Transformation Commands

To manipulate bodies (e.g., via drag-and-drop or property inspectors), capture the state when the interaction begins and finalize the command when the interaction ends.

class TransformCommand {
  constructor(body, beforeState, afterState) {
    this.body = body;
    this.beforeState = beforeState;
    this.afterState = afterState;
  }

  applyState(state) {
    Matter.Body.setPosition(this.body, state.position);
    Matter.Body.setAngle(this.body, state.angle);
    Matter.Body.setVelocity(this.body, state.velocity);
    Matter.Body.setAngularVelocity(this.body, state.angularVelocity);
    
    // Wake the body up if it was sleeping
    Matter.Sleeping.set(this.body, false);
  }

  undo() {
    this.applyState(this.beforeState);
  }

  redo() {
    this.applyState(this.afterState);
  }
}

Integrating with User Interactions

To prevent recording every intermediate frame during a drag operation, register the initial state on pointer down and the final state on pointer up:

let activeBody = null;
let initialState = null;

// User starts dragging a body
function onDragStart(body) {
  activeBody = body;
  initialState = captureBodyState(body);
}

// User releases the body
function onDragEnd() {
  if (!activeBody || !initialState) return;

  const finalState = captureBodyState(activeBody);

  // Check if any actual movement occurred
  const moved = 
    initialState.position.x !== finalState.position.x ||
    initialState.position.y !== finalState.position.y ||
    initialState.angle !== finalState.angle;

  if (moved) {
    const command = new TransformCommand(activeBody, initialState, finalState);
    historyManager.execute(command);
  }

  activeBody = null;
  initialState = null;
}

Handling Body Creation and Deletion

For actions that add or remove bodies from the physics world (Matter.Composite), use structural commands:

class AddBodyCommand {
  constructor(world, body) {
    this.world = world;
    this.body = body;
  }

  undo() {
    Matter.Composite.remove(this.world, this.body);
  }

  redo() {
    Matter.Composite.add(this.world, this.body);
  }
}

class DeleteBodyCommand {
  constructor(world, body) {
    this.world = world;
    this.body = body;
  }

  undo() {
    Matter.Composite.add(this.world, this.body);
  }

  redo() {
    Matter.Composite.remove(this.world, this.body);
  }
}

Best Practices for Physics State Restoration