How to Create a One-Way Platform in Planck.js?

One-way platforms are a staple of 2D platformer games, allowing a player character to jump up from below a platform but stand firmly on top of it. In planck.js (the JavaScript rewrite of the Box2D physics engine), implementing this mechanic requires using the global world contact listener system. Specifically, by intercepting the collision process within the preSolve lifecycle hook, you can evaluate the spatial or directional relationship between the player and the platform and selectively disable the collision before the physics solver processes it.

The Role of the PreSolve Listener

Planck.js provides several contact events, including beginContact, endContact, preSolve, and postSolve. While beginContact only fires once when two objects first touch, preSolve fires on every single physics step as long as the objects’ bounding boxes overlap. This makes preSolve the ideal hook for one-way platforms, because you can continuously determine whether the collision response should be active or inactive using the contact’s setEnabled(false) method.

Step-by-Step Implementation

To build a functional one-way platform, you must establish identifiable properties on your physics bodies or fixtures and register a custom listener on the world instance.

1. Labeling Objects with User Data

When creating your player and platform fixtures, attach distinct identifying labels to their userData properties. This allows your contact listener to easily distinguish them from other objects in the physics world.

// Define the static platform
const platformBody = world.createBody({ type: 'static', position: planck.Vec2(0, 5) });
platformBody.createFixture({
  shape: planck.Edge(planck.Vec2(-2, 0), planck.Vec2(2, 0)),
  userData: { type: 'one_way_platform' }
});

// Define the dynamic character
const playerBody = world.createBody({ type: 'dynamic', position: planck.Vec2(0, 2) });
playerBody.createFixture({
  shape: planck.Box(0.5, 1.0),
  userData: { type: 'player' }
});

2. Writing the Contact Listener Logic

Register a preSolve callback on your world instance. Within this function, you will extract both fixtures, verify that they represent the player and the platform, and inspect the player’s position relative to the platform edge.

world.on('preSolve', (contact, oldManifold) => {
  const fixtureA = contact.getFixtureA();
  const fixtureB = contact.getFixtureB();

  const dataA = fixtureA.getUserData();
  const dataB = fixtureB.getUserData();

  // Identify which fixture is the player and which is the platform
  let playerFixture = null;
  let platformFixture = null;

  if (dataA?.type === 'player' && dataB?.type === 'one_way_platform') {
    playerFixture = fixtureA;
    platformFixture = fixtureB;
  } else if (dataB?.type === 'player' && dataA?.type === 'one_way_platform') {
    playerFixture = fixtureB;
    platformFixture = fixtureA;
  }

  // If this contact does not involve our one-way platform, ignore it
  if (!playerFixture || !platformFixture) return;

  const playerBody = playerFixture.getBody();
  const platformBody = platformFixture.getBody();

  // Get positions
  const playerPos = playerBody.getPosition();
  const platformPos = platformBody.getPosition();

  // Determine the threshold for passing through
  // This assumes the platform's top surface is at platformPos.y
  // Adjust the offset based on your character's half-height
  const playerBottomY = playerPos.y - 1.0; 

  // If the player's feet are below the platform surface, disable collision
  if (playerBottomY < platformPos.y - 0.1) {
    contact.setEnabled(false);
  }
});

Alternative Direction-Based Approach

Checking positions works well for simple horizontal surfaces, but if your platform is moving or you want to base passing through purely on direction, you can look at the relative velocity or the collision normal.

Using contact.getWorldManifold() inside preSolve, you can extract the normal vector of the collision. If the character is moving upward or colliding from the bottom side of the platform shape, you can check if the relative velocity dot product with the normal vector is pointing in a direction that implies passing through, and call contact.setEnabled(false) accordingly. Note that because preSolve resets the enabled flag to true on every physics step, you must execute this evaluation continually until the player has completely cleared the platform’s boundary.