| name | platformer-movement |
| description | Platformer Movement in FlatRedBall2. Use when implementing platformer mechanics including jumping, ground detection, PlatformerBehavior, PlatformerValues, double jump, air control, variable-height jumps, or side-scrolling movement. Trigger on any platformer-related question. |
Platformer Movement
Overview
Platformer movement is provided by two classes in FlatRedBall2.Movement:
PlatformerValues — a plain data class holding movement parameters for one movement mode (Ground, Air, etc.)
PlatformerBehavior — a component added to an entity that reads input, drives the state machine, and applies velocity/acceleration each frame
Minimal Setup
Movement coefficients are recommended in a JSON file for fast tuning and hot-reload. Copy the template from .claude/templates/PlatformerConfig/player.platformer.json into the project's Content/ folder, adjust values, and add <Content Include="Content/*.json" CopyToOutputDirectory="PreserveNewest" /> to the .csproj. For prototypes/tests, equivalent hardcoded values in C# are valid.
public class Player : Entity
{
private readonly PlatformerBehavior _platformer = new();
public override void CustomInitialize()
{
PlatformerConfig.FromJson("Content/player.platformer.json").ApplyTo(_platformer);
var keyboard = Engine.Input.Keyboard;
_platformer.JumpInput = new KeyboardPressableInput(keyboard, Keys.Space)
.Or(new KeyboardPressableInput(keyboard, Keys.Up));
_platformer.MovementInput = new KeyboardInput2D(keyboard, Keys.Left, Keys.Right, Keys.Up, Keys.Down)
.Or(new KeyboardInput2D(keyboard, Keys.A, Keys.D, Keys.W, Keys.S));
}
public override void CustomActivity(FrameTime time)
{
_platformer.Update(this, time);
}
}
PlatformerConfig JSON
PlatformerConfig.FromJson(path) deserializes a JSON file into a pure data model; the ApplyTo extension method pushes it onto a PlatformerBehavior. The config is a model — it does not reference the behavior; ApplyTo lives in PlatformerConfigExtensions.
Template: .claude/templates/PlatformerConfig/player.platformer.json
Movement slots are fixed names mapping to behavior fields: ground → GroundMovement, air → AirMovement, climbing → ClimbingMovement (see "Climbing Ladders" below), afterDoubleJump → reserved (parsed but not applied until the behavior wires a double-jump slot). All fields in a slot are nullable; omitted fields fall back to new PlatformerValues() defaults.
Jump configuration supports two mutually-exclusive modes per slot:
- Derived (preferred):
minJumpHeight + optional maxJumpHeight → calls SetJumpHeights.
- Raw (escape hatch):
JumpVelocity + JumpApplyLength + JumpApplyByButtonHold set directly.
- Specifying fields from both modes in the same slot throws
InvalidOperationException.
Gravity gotcha — derived mode uses airborne gravity. The jump trajectory always runs under the air slot's Gravity (while grounded, collision cancels gravity — so ground.Gravity never acts on the arc). ApplyTo resolves this automatically: derived mode on the ground slot uses air.Gravity for its JumpVelocity/JumpApplyLength math; if no air slot is authored, it falls back to the slot's own Gravity. Mismatched ground/air gravities with derived-mode jumps will produce correct heights, but the ground slot's Gravity field itself is effectively ceremonial for trajectory purposes — keep it equal to air.Gravity for a clear mental model.
TimeSpan fields (AccelerationTimeX, DecelerationTimeX, JumpApplyLength) are represented as seconds (float) in JSON.
Critical Call-Order Rule
PlatformerBehavior.Update reads entity.LastReposition to determine ground state.
LastReposition is accumulated by SeparateFrom during collision resolution and reset at
the start of each PhysicsUpdate.
The Screen update order is: PhysicsUpdate → CollisionRelationships → CustomActivity.
This means Update called from CustomActivity will always see the current frame's
collision results — no special wiring needed.
PlatformerValues Fields
| Field | Description |
|---|
MaxSpeedX | Maximum horizontal speed in world units/sec |
AccelerationTimeX | Time to reach MaxSpeedX from rest. TimeSpan.Zero = instant |
DecelerationTimeX | Time to stop from MaxSpeedX. TimeSpan.Zero = instant |
Gravity | Downward acceleration (positive value, Y− direction applied internally). Only acts while airborne — collision cancels it while grounded. A ground slot's Gravity is used only as a fallback for SetJumpHeights when no air slot is present. |
MaxFallSpeed | Maximum downward speed (prevents infinite fall acceleration) |
JumpVelocity | Upward velocity applied when jump is triggered |
JumpApplyLength | How long to sustain JumpVelocity after pressing jump |
JumpApplyByButtonHold | If true, releasing jump early cuts the jump short |
SetJumpHeights(min, max?) | Computes JumpVelocity, JumpApplyLength, and JumpApplyByButtonHold from desired min/max jump heights in world units. Gravity must be set first. Prefer this over setting jump fields manually. |
Ground vs Air values
GroundMovement is used when IsOnGround == true; AirMovement is used otherwise.
If GroundMovement is null, AirMovement is used for both states.
Common patterns:
- Same values for ground and air: assign the same instance to both
- Reduced air control: set a lower
MaxSpeedX or AccelerationTimeX in AirMovement
- Ice: high
AccelerationTimeX and DecelerationTimeX in GroundMovement
Multiple Movement Sets (Water, Ice, Power-ups)
A PlatformerConfig JSON is a full description of a movement state. For a second context (swimming, ice, mud, power-up state) load a second JSON file into a second PlatformerConfig field and call ApplyTo(_platformer) on whichever one matches the current state. Call it each frame from CustomActivity — ApplyTo mutates the existing PlatformerValues instances in place (zero allocation on the hot path).
Replace semantics, not overlay. ApplyTo makes the behavior reflect the JSON exactly. Slots the JSON omits become null (for GroundMovement, AfterDoubleJump, ClimbingMovement) or reset to defaults (for AirMovement, which is non-nullable). To disable a slot in the alternate context (e.g. no double jump while swimming), simply omit afterDoubleJump from the water JSON — no code-side null-outs. Inside a present slot, fields the JSON omits reset to their PlatformerValues defaults, not the previous config's values.
Do not harvest PlatformerValues into local fields and swap them manually — that's more code for the same outcome, and fights the zero-allocation in-place mutation.
Reading State
_platformer.IsOnGround
_platformer.IsApplyingJump
_platformer.DirectionFacing
_platformer.GroundHorizontalVelocity
Double Jump (Air Jumps)
PlatformerBehavior only jumps from the ground. Implement air jumps manually in the entity:
private int _airJumpsRemaining;
private const int MaxAirJumps = 1;
if (_platformer.IsOnGround)
_airJumpsRemaining = MaxAirJumps;
if (_platformer.JumpInput.WasJustPressed && !_platformer.IsOnGround && !_platformer.IsApplyingJump && _airJumpsRemaining > 0)
{
VelocityY = _platformer.AirMovement.JumpVelocity;
_airJumpsRemaining--;
}
Read the jump trigger off _platformer.JumpInput rather than the raw keyboard — that way the air jump automatically honors whatever binding (Space+Up, gamepad A, etc.) was configured in CustomInitialize via .Or(...).
The !_platformer.IsApplyingJump guard prevents the air jump from triggering during the sustain phase of the first jump — the player must reach the peak before double-jumping.
Entity Origin and Shape Offset
In platformers, an entity's Y position represents the feet — the point collision resolves ground contact against. The collision shape must be offset upward so its bottom edge is flush with Y=0; this is non-negotiable, it's what "standing on the ground" means physically:
var collisionBox = new AARect
{
Width = 12,
Height = 28,
Y = 14,
};
Add(collisionBox);
The sprite's RelativeY is different — it's art, not physics. It only needs to match the shape's flush-bottom offset for a strictly side-on camera (Mario-style). A platformer with a tilted camera (Donkey Kong Country-style) shows a sliver of the ground plane, so the sprite's ground-contact point sits inside its bounding box and must be set by eye — see animation skill's references/achx-authoring.md (Ground-Contact Point section). RelativeX stays 0 in both cases since sprites already draw X-centered.
If you skip the shape offset, the character's feet will not align with the ground.
Collision Setup
Use BounceFirstOnCollision(elasticity: 0f) — not MoveFirstOnCollision. The solid side can be a TileShapes (for static level geometry) or an entity factory (for moving platforms, destructible blocks, etc.):
screen.AddCollisionRelationship(playerFactory, tileShapeCollection)
.BounceFirstOnCollision(elasticity: 0f);
screen.AddCollisionRelationship<Player, MovingPlatform>(playerFactory, platformFactory)
.BounceFirstOnCollision(elasticity: 0f);
BounceFirstOnCollision (which expands to the bounce with the player fully displaced
and the solid fixed) both separates the player (populating LastReposition for ground
detection) and zeroes the velocity component into the surface. Without it, hitting a
ceiling leaves the player with upward velocity and they float against it.
MoveFirstOnCollision only repositions — it never touches velocity, which is wrong for
platformer collision.
Entity solids arranged in a grid (brick rows, crate stacks, destructible walls) must set factory.IsSolidGrid = true — otherwise the player snags on seams between adjacent entities (each body resolves separation independently). See entities-and-factories. Use Overlay.DrawSolidSides(factory) in CustomActivity to visualize.
Slopes and Ramps
Set SlopeMode = SlopeCollisionMode.PlatformerFloor on the player's collision relationship (not on the TileShapes) to enable slope collision for polygon tiles. In this mode:
- Polygon tiles push vertically only (heightmap-based). The polygon's surface Y at the player's center X determines the push. No horizontal component means no snagging at slope seams.
- Rect tiles next to polygon tiles get their shared face suppressed automatically (like adjacent rects already do).
- Preferential landing: if the player is falling and standard collision would push them sideways off a ledge edge, they land on top instead. Only fires when the tile's Up face is active (nothing above it).
Create slope tiles with AddPolygonTileAtCell:
var upRampSlope = Polygon.FromPoints(new[]
{
new Vector2(-8f, -8f),
new Vector2( 8f, -8f),
new Vector2( 8f, 8f),
});
tileShapeCollection.AddPolygonTileAtCell(col, row, upRampSlope);
var playerVsTiles = AddCollisionRelationship(_playerFactory, tileShapeCollection);
playerVsTiles.SlopeMode = SlopeCollisionMode.PlatformerFloor;
playerVsTiles.BounceFirstOnCollision(elasticity: 0f);
Default is SlopeCollisionMode.Standard (SAT collision for polygon tiles), which is correct for top-down games but causes snagging in platformers. SlopeMode is per-relationship so the same tile collection can be shared by a platformer player (PlatformerFloor) and other entities (Standard, e.g. a kicked ball) at the same time.
Ground Snapping (Slope Adherence)
Players who run off a downslope or off the top of an up-ramp onto lower flat ground will briefly go airborne for a frame without snapping — a standard platformer feature eliminates this by "hugging" the entity to a nearby surface.
Wiring checklist — all three conditions must hold for snap to fire:
- The player entity implements
IPlatformerEntity (exposes Platformer => _platformer)
- A
CollisionRelationship between the player and a TileShapes has SlopeMode = SlopeCollisionMode.PlatformerFloor — each such relationship automatically contributes its collection as a snap probe target
PlatformerBehavior.CollisionShape is set to the player's collision AARect, and PlatformerValues.SlopeSnapDistance > 0 on the active values set (default 8f), and the entity was on a sloped surface last frame (CurrentSlope != 0)
public class Player : Entity, IPlatformerEntity
{
private readonly PlatformerBehavior _platformer = new();
public PlatformerBehavior Platformer => _platformer;
_platformer.CollisionShape = body;
}
var playerVsSolid = AddCollisionRelationship(_playerFactory, _solid);
playerVsSolid.SlopeMode = SlopeCollisionMode.PlatformerFloor;
No explicit snap target. A player can have multiple PlatformerFloor relationships
(solid level, moving platforms, one-way floors, etc.) and every one of them contributes as a
snap candidate — the first one to produce a hit within the frame wins, and subsequent
relationships no-op for the rest of the frame.
Partial-config throws. If the active values have SlopeSnapDistance > 0 and a
PlatformerFloor relationship dispatches while CollisionShape is null,
ConsiderSnappingTo throws InvalidOperationException. Set SlopeSnapDistance = 0 on
values that should opt out of snap instead of leaving CollisionShape null.
Debugging snap with OnSnapDiagnostic. If snap isn't firing, assign a callback to get a
one-line reason per frame (success or skip):
_platformer.OnSnapDiagnostic = msg => System.Diagnostics.Debug.WriteLine(msg);
Messages begin with "snap: " on success or "skip: <reason>" when a gate aborted. When the
callback is null there is no allocation cost.
Feet Y is derived from the shape (AbsoluteY - Height/2) at probe time, so the shape can be placed anywhere relative to the entity origin without additional configuration.
Tuning lives on PlatformerValues:
| Field | Default | Meaning |
|---|
SlopeSnapDistance | 8f | Max downward probe distance. 0 disables snap for this values set. Snap is also gated on CurrentSlope != 0 (was on a sloped surface last frame) — flat-to-flat cliff drops fall ballistically regardless of this value. |
SlopeSnapMaxAngleDegrees | 60f | Surfaces whose upward normal is within this many degrees of straight up qualify as "walkable" for snap. |
The mechanism:
- If the player was grounded last frame, is not grounded this frame, is not rising, and was on a sloped surface last frame (
CurrentSlope != 0),
- Raycast straight down from the player's feet by
SlopeSnapDistance,
- If it hits a walkable surface (normal within the angle threshold), move the player onto it, zero
VelocityY, and set IsOnGround = true.
Flat-to-flat ledges fall ballistically. The slope gate means walking off the edge of a flat tile onto a lower flat tile does not snap — behaves as a cliff drop, matching classic platformer feel. Snap is specifically for hugging downslopes across tile seams, not for stepping onto lower platforms.
Per-values-set config is intentional. A walking state wants snap on; a ball/wheel state that wants Sonic-style launches off ramps should set SlopeSnapDistance = 0 on its PlatformerValues so it flies off ramps naturally.
The "was grounded last frame" gate is what makes jumps work. Without it, snap would yank the player back to the floor on the first frame of every jump. Don't attempt to bypass it.
Slope Speed Adjustment
PlatformerBehavior.CurrentSlope (signed degrees, +X-rise positive, 0 when airborne/flat) is refreshed each frame by a short downward raycast contributed by every PlatformerFloor relationship. Defaults are active, not opt-in — any platformer with slope tiles immediately gets the classic "slow going up, faster going down" feel without additional configuration.
| Field | Default | Meaning |
|---|
UphillFullSpeedSlope | 0 | Below this, full MaxSpeedX going uphill. |
UphillStopSpeedSlope | 60 | At/above this, uphill speed = 0. Linearly interpolated between. Set equal to UphillFullSpeedSlope to disable slowdown. |
DownhillFullSpeedSlope | 0 | Below this, downhill uses unmodified MaxSpeedX. |
DownhillMaxSpeedSlope | 60 | At/above this, downhill speed is multiplied by DownhillMaxSpeedMultiplier. Linearly interpolated between. |
DownhillMaxSpeedMultiplier | 1.5 | Peak multiplier. Set to 1 to disable downhill boost. |
Under defaults, a 30° slope cuts uphill speed to 50% and boosts downhill speed to 125%; a 45° slope is 25% / 137.5%. Uphill vs downhill is determined by sign(inputX) == sign(CurrentSlope).
Requires SlopeMode = SlopeCollisionMode.PlatformerFloor on the player's collision relationship — without it, CurrentSlope stays 0 and the multipliers collapse to 1.
When using acceleration, the adjusted max speed drives AccelerationTimeX magnitude (speeding up); DecelerationTimeX still uses the raw MaxSpeedX so braking isn't slowed on an uphill.
Moving Platforms
Standing on another Entity with non-zero VelocityX automatically transfers that horizontal
velocity to the platformer entity for the frame — the player rides the platform with no input,
and a jump carries the platform's momentum into the air. No opt-in needed: any bounce
relationship between an IPlatformerEntity and a regular Entity (not a TileShapes)
gets this behavior whenever the separation pushes the platformer upward.
AddCollisionRelationship<Player, MovingPlatform>(_playerFactory, _platformFactory)
.BounceFirstOnCollision(elasticity: 0f);
The platform's own movement (path-follower, ping-pong in CustomActivity, etc.) is independent —
the engine just reads its VelocityX at collision time. Tile collections are excluded by design;
moving level geometry should be authored as entities.
Animation gotcha: standing still on a moving platform leaves the player with VelocityX != 0 (they inherit the platform's velocity), so drive walk/idle animations off MovementInput.X, not VelocityX — see the animation section below.
One-Way Platforms and Drop-Through
Jump-through (cloud) platforms are configured on the collision relationship, not the behavior — set relationship.OneWayDirection = OneWayDirection.Up and relationship.CanDropThrough = true. The second flag is required for drop-through to bypass the relationship; leaving it false makes the barrier hard (e.g. Yoshi's Island ratchet doors — always blocks, Down+Jump has no effect on it). See the collision-relationships skill for the relationship-level semantics.
Drop-through is handled by the behavior. Set PlatformerValues.CanFallThroughOneWayCollision = true (default) to enable; false makes Down+Jump perform a normal jump and airborne Down has no effect.
Triggers:
- Grounded Down+Jump — suppresses one-way collision for one frame and skips the regular jump. After that frame, the entity's
LastPosition is below the surface, so the one-way gate's positional check naturally prevents re-landing.