| 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 bottom of the character). This means collision shapes and sprites must be offset upward so their bottom edge aligns with Y=0 on the entity:
var collisionBox = new AARect
{
Width = 12,
Height = 28,
Y = 14,
};
Add(collisionBox);
The same applies to sprites — the .achx template uses <RelativeY>16</RelativeY> on a 32-pixel-tall character for exactly this reason (16 = half of 32).
If you skip this 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.
- Airborne Down held (
MovementInput.Y < -0.5) — continuous suppression while falling, so the player can ride a downward arc through stacked clouds.
PlatformerBehavior.IsSuppressingOneWayCollision reflects the combined state; the one-way gate on each relationship reads it via the player's IPlatformerEntity.Platformer, but only when the relationship has CanDropThrough = true. Relationships with CanDropThrough = false (the default) ignore the suppression flag so hard one-way barriers remain impassable.
Climbing Ladders (and SMW-Style Fences)
Climbing is a fourth movement slot alongside GroundMovement, AirMovement, and the reserved AfterDoubleJump — authored the same way in JSON, selected by a game-set flag at runtime. The same mechanic covers vertical ladders (single column) and Super Mario World-style fences (multi-column climbable surfaces with potentially uneven tops).
The engine's contract. When PlatformerBehavior.IsClimbing == true (and ClimbingMovement is assigned), Update switches the active slot to ClimbingMovement, zeros AccelerationY, drives VelocityY = MovementInput.Y * ClimbingSpeed, and skips fall-speed clamping and drop-through. On a jump press it clears IsClimbing and applies the climbing slot's JumpVelocity/JumpApplyLength/JumpApplyByButtonHold (same shape as a ground jump). Horizontal movement runs under the same accel/decel path as the other slots using the climbing slot's MaxSpeedX.
What the engine does NOT do. Detect ladders. Decide when to enter. Snap the player's X to a ladder's center. Handle walking off the top or falling off the side. Those are game-code concerns — the flag is the full contract.
JSON
"climbing": {
"MaxSpeedX": 80,
"ClimbingSpeed": 120,
"JumpVelocity": 250
}
Consumed while climbing: MaxSpeedX, AccelerationTimeX, DecelerationTimeX, ClimbingSpeed, JumpVelocity, JumpApplyLength, JumpApplyByButtonHold.
Ignored while climbing (parse without error, no effect): Gravity, MaxFallSpeed, all slope fields, CanFallThroughOneWayCollision.
Assign Ladders / Fences on PlatformerBehavior — the canonical pattern
Climbing is configuration on PlatformerBehavior itself: assign one or both TileShapes surfaces to the Ladders and Fences properties, and Update handles the rest — body-range column detection, enter/exit triggers, ladder X-snap + re-pin, fence 2D traversal, and per-frame TopOfLadderY tracking. There is one Update call whether or not climbing is used.
public class Player : Entity, IPlatformerEntity
{
private readonly PlatformerBehavior _platformer = new();
private AARect _body = null!;
public PlatformerBehavior Platformer => _platformer;
public TileShapes? Ladders { get => _platformer.Ladders; set => _platformer.Ladders = value; }
public TileShapes? Fences { get => _platformer.Fences; set => _platformer.Fences = value; }
public override void CustomInitialize()
{
_body = new AARect { Width = 12f, Height = 20f, Y = 10f };
Add(_body);
PlatformerConfig.FromJson("Content/player.platformer.json").ApplyTo(_platformer);
_platformer.CollisionShape = _body;
}
public override void CustomActivity(FrameTime time) => _platformer.Update(this, time);
}
Screen wiring — no AddCollisionRelationship for ladders/fences; overlap is polled directly:
var solids = map.GenerateCollisionFromClass("SolidCollision");
var ladders = map.GenerateCollisionFromClass("Ladder");
var fences = map.GenerateCollisionFromClass("Fence");
Add(solids); Add(ladders); Add(fences);
var player = map.CreateEntities("PlayerSpawn", _playerFactory, Origin.BottomCenter)[0];
player.Ladders = ladders;
player.Fences = fences;
AddCollisionRelationship(_playerFactory, solids).BounceFirstOnCollision(elasticity: 0f);
Surfaces have distinct semantics:
Ladders — on entry, snaps X to the ladder column's center and re-pins every frame. Horizontal input is ignored. Vertical-only.
Fences — on entry, X is preserved. Horizontal input remains active while climbing (SMW-style 2D traversal).
Enter triggers (defaults): overlap + press Up (ladders or fences), or grounded + press Down with a ladder cell directly below the feet (climb-down-from-top, ladders only). Exit: lost body overlap; grounded with inputY <= 0 (landed while descending — not on the entry frame); or clamped at TopOfLadderY with |inputX| > threshold (step-off top — player presses left/right at the top to land on the platform above, snapping feet to TopOfLadderY so the next frame's collision catches them). Jump-off is handled by PlatformerBehavior (pressing jump while climbing applies ClimbingMovement.JumpVelocity). Read state via platformer.IsOnLadder / platformer.IsOnFence.
Manual state machine (advanced, rarely needed)
If the built-in triggers don't fit (e.g. a "climb only on button press" scheme, or walk-off-the-top behavior), leave Ladders/Fences null and drive IsClimbing and TopOfLadderY yourself. The contract is: set IsClimbing = true while overlapping, set TopOfLadderY each frame to clamp the top (or leave it null for walk-off-the-top), clear IsClimbing when exit conditions fire. The built-in climb gate in PlatformerBehavior.Update is the reference implementation.
TopOfLadderY semantics
TopOfLadderY clamps Y and zeros upward velocity — the player can hold Up forever and won't pass the top. Pressing left or right while clamped at the top exits climbing and snaps feet to TopOfLadderY; the platform's collision then catches the player on the next frame. When Ladders/Fences is assigned, it is set automatically each frame to the top edge of the currently-overlapping column, which gives the standard "stop at the top of the column" behavior and makes SMW-style uneven fence tops work for free. To get walk-off-the-top behavior (rare), use the manual state machine with TopOfLadderY = null.
Gotchas
CollisionShape is required when Ladders or Fences is assigned. Used for body-range column detection. Throws InvalidOperationException on first Update if null and any surface is assigned.
- Optional
ClimbingShape decouples climb feel from physical body. Assign a separate AARect (parented to the entity) to PlatformerBehavior.ClimbingShape to use it for ladder/fence overlap instead of CollisionShape. A narrow rect at the body center gives Mario-style "must be centered on the ladder" detection (player can hang over the edge before grabbing/letting go); a wider rect gives a generous grab. Leave null to use CollisionShape (default — fine for most games).
- Always author
JumpVelocity on the climbing slot. Defaults to 0 and "field omitted" is indistinguishable from "explicit 0" once parsed. A climbing slot without JumpVelocity makes jump-off look broken — the player presses jump, leaves the ladder, and drops straight down. The engine does not fall back to AirMovement.JumpVelocity.
ClimbingMovement null while IsClimbing == true throws — loud-failure pattern. PlatformerConfig.FromJson assigns it when the climbing slot is present; if you're driving state manually, assign before setting IsClimbing = true.
- No
CollisionRelationship between player and ladders/fences. Overlap is polled directly. Adding a relationship causes double-handling and physical separation (which fights the climb).
- Collision relationships with solids still run. If ladder-adjacent walls or one-way platforms should be passable while climbing (e.g., climbing up through a jump-through platform a ladder passes through), the game must skip those relationships or rely on
CanDropThrough behavior while IsClimbing — climbing does not auto-suppress them.
IPlatformerEntity is not required purely for climbing. The climbing path uses no collision relationship — direct overlap polling. Implement IPlatformerEntity only if the player also needs ground-snap slopes.
Platformer Animations (User Code, Not Engine-Managed)
Always use the template animations for platformer characters — copy PlatformerAnimations.achx and AnimatedSpritesheet.png from .claude/templates/AnimationChains/ into the project's Content/Animations/ directory and load via .achx. Do not fall back to a shape placeholder for the player character.
The template chain names follow the pattern Character<State><Direction> — e.g. CharacterIdleRight, CharacterWalkLeft, CharacterRunJumpRight. There is no separate Fall chain; use CharacterRunJump for both jump and fall.
FRB2 does not provide an animation controller. Animation state selection is straightforward game code — a pattern match on PlatformerBehavior state, plus a facing suffix:
private void UpdateAnimation()
{
if (_platformer.IsClimbing)
{
_sprite.PlayAnimation("CharacterClimbFront");
return;
}
float inputX = _platformer.MovementInput?.X ?? 0f;
string chain = (_platformer.IsOnGround, MathF.Abs(inputX) > 0.1f) switch
{
(true, false) => "CharacterIdle",
(true, true) => "CharacterWalk",
_ => "CharacterRunJump",
};
chain += _platformer.DirectionFacing == HorizontalDirection.Left ? "Left" : "Right";
_sprite.PlayAnimation(chain);
}
Do not pause the climb animation when the player is still on the ladder. Animation runs continuously — a "hanging on the ladder, not moving" state is a content choice (a 1-frame chain, or a multi-frame chain with subtle motion like the player's hair drifting). See the animation skill's "Never pause animation to express still state" gotcha.
Why input, not velocity? Velocity-based detection (Math.Abs(VelocityX) > threshold) breaks on moving platforms — the player stands still, inherits the platform's velocity, and the walk animation loops as if they're running. It also breaks on slippery ground — after releasing input the player is still sliding, so the walk animation keeps playing through the decel. Input-based selection is simpler and matches FRB1's state-driven approach. For games that genuinely want a slide animation distinct from walk, branch on GroundHorizontalVelocity (platform contribution this frame) or VelocityX - GroundHorizontalVelocity (speed relative to the platform).
Call UpdateAnimation() at the end of CustomActivity, after _platformer.Update. PlayAnimation is idempotent — calling with the same chain name each frame does not restart the animation.
For additional states (run, land, wall-slide, double-jump), add cases to the pattern match. Priority is explicit in the code order — no registration API or priority constants needed.
For non-looping animations (attack, land), set _sprite.IsLooping = false and use the AnimationFinished event to transition back to the default state.
Gotchas
JumpApplyLength = TimeSpan.Zero means no jump sustain — velocity is set once on press and immediately stops being held. This gives a fixed-height jump regardless of JumpApplyByButtonHold.
MaxFallSpeed must be > 0 or the entity will be clamped to 0 downward velocity. Set it to a large value (e.g. 1000) if you don't want a meaningful cap.
- Ground detection is
LastReposition.Y > 0 — a purely horizontal collision (wall) does not register as ground.
- Slopes require
SlopeMode = SlopeCollisionMode.PlatformerFloor on the player's collision relationship. The default Standard mode will cause the player to snag at polygon/rect seams.