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.