How to Simulate a Snake Robot in Matter.js

This article details how to build and simulate a planar snake robot in the Matter.js physics engine using chained rigid bodies, rotational constraints, and sinusoidal joint actuation. You will learn the mechanics behind snake-like lateral undulation, how to construct the multi-link physical assembly, how to generate a traveling serpenoid wave, and how to implement the anisotropic friction required to translate lateral bending into forward propulsion.

1. Understanding Snake Locomotion Physics

Biological snakes achieve forward movement primarily through lateral undulation. This process requires two mechanical components:

  1. A Serpenoid Wave: A traveling wave of curvature along the body, characterized by joint angles that vary sinusoidally with a phase offset between adjacent segments: \[\theta_i(t) = A \cdot \sin(\omega t + i \cdot \phi)\] where \(A\) is the amplitude, \(\omega\) is the angular frequency, \(i\) is the joint index, and \(\phi\) is the phase shift between segments.
  2. Anisotropic Friction: In nature, snake scales have low friction in the forward direction and high friction perpendicular to the body. Without anisotropic ground friction, a simulated snake will simply thrash in place without moving forward.

2. Constructing the Robot Segments and Joints

Create a chain of rectangular segments linked by revolute-style constraints using Matter.js primitives.

const { Engine, Render, Runner, Bodies, Body, Constraint, Composite, Vector } = Matter;

const engine = Engine.create();
const world = engine.world;
engine.gravity.y = 0; // Top-down view requires disabling vertical gravity

const numSegments = 10;
const segmentWidth = 40;
const segmentHeight = 12;
const segments = [];
const constraints = [];

// Create segments
for (let i = 0; i < numSegments; i++) {
  const x = 200 + i * segmentWidth;
  const y = 300;
  const segment = Bodies.rectangle(x, y, segmentWidth, segmentHeight, {
    collisionFilter: { group: -1 }, // Prevent self-collision between segments
    frictionAir: 0.05
  });
  segments.push(segment);
  Composite.add(world, segment);
}

// Connect segments with pivot constraints
for (let i = 0; i < numSegments - 1; i++) {
  const joint = Constraint.create({
    bodyA: segments[i],
    bodyB: segments[i + 1],
    pointA: { x: segmentWidth / 2, y: 0 },
    pointB: { x: -segmentWidth / 2, y: 0 },
    stiffness: 0.9,
    length: 0
  });
  constraints.push(joint);
  Composite.add(world, joint);
}

3. Implementing the Sinusoidal Joint Controller

Because Matter.js does not include native angular servo motors, you can actuate the joints by applying equal and opposite torques to adjacent segments based on the error between their current relative angle and the target serpenoid angle.

In a proportional controller: \[\tau = K_p \cdot (\theta_{\text{target}} - \theta_{\text{current}}) - K_d \cdot (\omega_A - \omega_B)\]

const amplitude = 0.6;   // Max bending angle in radians
const frequency = 4.0;   // Oscillation speed
const phaseShift = 0.8;  // Spatial wave delay between joints
const kp = 0.08;         // Proportional gain (torque strength)
const kd = 0.01;         // Damping gain

Matter.Events.on(engine, 'beforeUpdate', (event) => {
  const time = event.timestamp / 1000;

  for (let i = 0; i < constraints.length; i++) {
    const bodyA = segments[i];
    const bodyB = segments[i + 1];

    // Compute desired relative angle
    const targetAngle = amplitude * Math.sin(frequency * time + i * phaseShift);

    // Compute current relative angle (normalized between -PI and PI)
    let currentAngle = bodyB.angle - bodyA.angle;
    while (currentAngle > Math.PI) currentAngle -= 2 * Math.PI;
    while (currentAngle < -Math.PI) currentAngle += 2 * Math.PI;

    // Calculate PD control torque
    const angleError = targetAngle - currentAngle;
    const relativeAngularVelocity = bodyB.angularVelocity - bodyA.angularVelocity;
    const torque = kp * angleError - kd * relativeAngularVelocity;

    // Apply opposing torques to actuate the joint
    bodyA.torque -= torque;
    bodyB.torque += torque;
  }
});

4. Simulating Anisotropic Ground Friction

Matter.js assumes isotropic friction by default. To emulate snake scales or passive passive caster wheels, you must manually cancel out lateral motion while preserving longitudinal motion for each segment during every physics step.

Matter.Events.on(engine, 'beforeUpdate', () => {
  const lateralFrictionCoefficient = 0.85;

  segments.forEach((segment) => {
    // Normal vector perpendicular to the segment's length
    const normal = {
      x: -Math.sin(segment.angle),
      y: Math.cos(segment.angle)
    };

    // Calculate lateral velocity component (dot product of velocity and normal)
    const lateralVelocityMag = segment.velocity.x * normal.x + segment.velocity.y * normal.y;
    
    // Apply a lateral counter-force to resist sideways slipping
    const opposingForce = {
      x: -normal.x * lateralVelocityMag * segment.mass * lateralFrictionCoefficient,
      y: -normal.y * lateralVelocityMag * segment.mass * lateralFrictionCoefficient
    };

    Body.applyForce(segment, segment.position, opposingForce);
  });
});

5. Running and Tuning the Simulation

Initialize the renderer and runner to observe the robot's movement:

const render = Render.create({
  element: document.body,
  engine: engine,
  options: {
    width: 800,
    height: 600,
    wireframes: false
  }
});

Render.run(render);
Runner.run(Runner.create(), engine);

To tune the locomotion: