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:
- 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.
- 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
- Reset Momentum: When restoring position during edit
mode, explicitly set
velocityandangularVelocityto zero to prevent residual forces from launching objects unexpectedly. - Update Bounding Boxes: Matter.js handles body
bounds automatically when using
Matter.Body.setPositionandMatter.Body.setAngle. Avoid mutatingbody.position.xdirectly, as this bypasses internal collision tree (broadphase) updates. - Compound Bodies: If manipulating compound bodies, apply transformations directly to the root body rather than individual parts to maintain correct relative offsets.