Box2D Character Mover
A platformer character with no rigid body, on Box2D v3's mover API: a capsule the game moves itself, Quake style, asking the world only what it touches - collect the contact planes, solve a translation that respects them, sweep it - with a pogo shape cast from the feet that floats it above the ground. The course is the samples' own: two Inkscape outlines read into chains, a fifty-plank bridge that sags under the character, a ball to kick, a soft capsule to walk through and a rigid elevator to ride. A and D walk, Space jumps, K kicks, Z picks the pogo shape, and the grabber throws things at the character.
The Program.cs file shows how to:
- A character as a capsule plus a transform, not a body: why a rigid-body character fights you
- The mover loop: CollideMover for planes, SolvePlanes for a translation, CastMover for the sweep
- The pogo spring from a shape cast, and how it decides whether the character is on the ground
- Per-shape softness: a push limit and a velocity clip flag in the shape's user data
- Category bits so the mover overlaps some shapes, sweeps against others and kicks the rest
- Level outlines from SVG paths with SvgPath2D, as closed chain shapes
- A kinematic elevator driven by target transforms inside the fixed step
- Using helpers: CharacterMover2D, SvgPath2D, Joints2D.CreateRevolute, Grabber2DScript, Box2DDebugDraw, DebugTextDropdown
Note
This example references Stride.CommunityToolkit.Box2D, which is not on NuGet yet. Run it from a clone of the
repository, where the package is a project reference; a copy of the project on its own will not build.

View on GitHub.
using Box2D.NET;
using Stride.CommunityToolkit.Box2D;
using Stride.CommunityToolkit.Engine;
using Stride.CommunityToolkit.Rendering.ProceduralModels;
using Stride.CommunityToolkit.Scripts;
using Stride.CommunityToolkit.Scripts.Utilities;
using Stride.CommunityToolkit.Shapes;
using Stride.Core.Mathematics;
using Stride.Engine;
using Stride.Games;
using Stride.Input;
using static Box2D.NET.B2Bodies;
using static Box2D.NET.B2Geometries;
using static Box2D.NET.B2MathFunction;
using static Box2D.NET.B2Shapes;
using static Box2D.NET.B2Types;
// A platformer character that is not a rigid body. Ported from the Box2D.NET samples' Mover
// scene (MIT, (c) 2022 Erin Catto, (c) 2025 Choi Ikpil).
//
// A rigid-body character fights you: it tips, it bounces, it sticks to walls, it needs its
// rotation locked and its friction faked. Box2D v3 offers another way - the mover API - where the
// character is just a capsule the game moves itself, Quake style, and the world is asked only what
// it touches: collect the contact planes, solve a translation that respects them, sweep it. A
// "pogo" shape cast down from the feet drives a spring that floats the capsule above the ground
// and tells it whether it is standing. CharacterMover2D is that recipe; this scene is the samples'
// course for it.
//
// The level is two outlines drawn in Inkscape and read with SvgPath2D into chain shapes; a
// fifty-plank bridge on sprung revolute joints sags under the character; a bouncy ball can be
// kicked; a "friendly" capsule is soft - the mover walks through it slowly - and an elevator is
// rigid and carries the mover. That softness is per shape: a push limit and a clip flag in the
// shape's user data.
//
// A and D walk, Space jumps, K kicks the ball, Z picks the pogo shape, G shows Box2D's own
// contact points, R resets. The character walks by itself, jumping walls, until A or D is pressed.
const float Scale = 0.2f;
const int PlankCount = 50;
const float BridgeStart = 48.7f;
const float BridgeHeight = 9.2f;
const float ElevatorAmplitude = 4f;
// Where the elevator rides, where the character starts and where the ball waits: R puts the last two back.
var elevatorBase = new Vector2(112, 10);
var spawn = new Vector2(2, 8);
var ballStart = new Vector2(7, 7);
// The scene the local functions share, filled in by Start and read every frame by Update and Draw.
Box2DSimulation? simulation = null;
ShapeBatch? shapeBatch = null;
Basic2DCameraController? cameraController = null;
Box2DDebugDraw? debugDraw = null;
CharacterMover2D? mover = null;
ShapeComponent? heroShape = null;
DebugTextDropdown? pogoMenu = null;
List<Vector2[]> terrain = [];
B2BodyId ballBody = default;
var showContacts = false;
var autoWalk = true;
var kickFlash = 0f;
// The testbed grey the samples draw on.
var background = new Color(0.2f, 0.2f, 0.2f);
using var game = new Game();
// Start builds the course, Update drives it; the simulation is created in Start and outlives Run.
game.Run(start: Start, update: Update);
simulation?.Dispose();
void Start(Scene scene)
{
game.Window.AllowUserResizing = true;
game.Window.Title = "Box2D Character Mover - Stride Community Toolkit";
game.SetupBase2D(clearColor: background);
var cameraEntity = game.Add2DCameraController();
cameraController = cameraEntity.Get<Basic2DCameraController>();
game.AddProfiler();
// Zoomed out enough to see the bridge whole; the camera follows the hero from BuildHero on.
var camera = scene.GetCamera() ?? throw new InvalidOperationException("Camera not found in scene");
camera.OrthographicSize = 18;
shapeBatch = game.AddShapeBatch();
shapeBatch.BorderWidth = 1f;
shapeBatch.Fill.Alpha = 0.4f;
simulation = new Box2DSimulation();
// Box2D's own view of the contacts, for when the planes the mover collects are not enough.
debugDraw = new Box2DDebugDraw(shapeBatch) { DrawShapes = false, DrawJoints = false, DrawContactPoints = true, DrawContactNormals = true };
cameraEntity.Add(new Grabber2DScript { Simulation = simulation });
// The two grounds sit side by side; the bridge spans the gap between them.
var ground1 = BuildGround(GroundPath1, new Vector2(0, 0), new Vector2(-50, -200));
var ground2 = BuildGround(GroundPath2, new Vector2(98, 0), new Vector2(0, -200));
// The course, in the order the sample builds it.
BuildBridge(scene, ground1, ground2);
BuildFriend(scene);
BuildBall(scene);
BuildElevator(scene);
BuildHero(scene);
SetupMenu();
AddInstructions();
}
// One ground outline: the sample's Inkscape path, offset and scaled into metres, as a closed chain
// on a static body at bodyPosition. Chain points are in the body's frame, so the drawn copy is
// shifted into the world.
B2BodyId BuildGround(string path, Vector2 bodyPosition, Vector2 svgOffset)
{
var local = SvgPath2D.Parse(path, svgOffset, Scale);
var body = simulation!.CreateStaticBody(new Vector3(bodyPosition, 0));
ShapeFixtureBuilder.AttachChain(local, body, isLoop: true);
terrain.Add([.. local.Select(p => p + bodyPosition)]);
return body;
}
// Fifty planks on revolute joints with a soft spring and a small motor torque - the spring keeps
// the bridge from folding, the motor gives it a little stiffness - hung between the two grounds.
void BuildBridge(Scene scene, B2BodyId ground1, B2BodyId ground2)
{
var options = new RevoluteJointOptions { EnableSpring = true, Hertz = 3, DampingRatio = 0.8f, EnableMotor = true, MaxMotorTorque = 10 };
Vector2[] plank = [new(-0.5f, -0.125f), new(0.5f, -0.125f), new(0.5f, 0.125f), new(-0.5f, 0.125f)];
// Each plank hinges on the one before it: the first on ground one, the last pinned to ground two.
var previous = ground1;
for (var i = 0; i < PlankCount; i++)
{
var centre = new Vector2(BridgeStart + 0.5f + i, BridgeHeight);
var entity = Spawn(scene, "Plank", new ShapeComponent { Vertices = [.. plank], Color = Color.RoyalBlue }, centre);
var body = simulation!.CreateDynamicBody(entity, new Vector3(centre, 0));
b2Body_SetAngularDamping(body, 0.2f);
ShapeFixtureBuilder.AttachShape(Primitive2DModelType.Rectangle, new Vector2(1f, 0.25f), body);
entity.Add(new Box2DBodyComponent { BodyId = body });
simulation.Joints.CreateRevolute(previous, body, new Vector2(BridgeStart + i, BridgeHeight), options);
previous = body;
}
simulation!.Joints.CreateRevolute(previous, ground2, new Vector2(BridgeStart + PlankCount, BridgeHeight), options);
}
// A static capsule in the mover category: the mover overlaps it but never sweeps against it, and
// a tiny push limit with no velocity clip makes it soft - walk into it and you pass through slowly.
void BuildFriend(Scene scene)
{
var position = new Vector2(32, 4.5f);
var entity = Spawn(scene, "Friend", new ShapeComponent { Vertices = [new(0, -0.5f), new(0, 0.5f)], Radius = 0.3f, Color = Color.PaleGreen }, position);
var body = simulation!.CreateStaticBody(entity, new Vector3(position, 0));
var def = b2DefaultShapeDef();
def.filter = new B2Filter(CharacterMover2D.MoverCategory, ulong.MaxValue, 0);
var capsule = new B2Capsule(new B2Vec2(0, -0.5f), new B2Vec2(0, 0.5f), 0.3f);
var shape = b2CreateCapsuleShape(body, in def, in capsule);
// Twenty-five millimetres of push a step and no velocity clip: soft.
CharacterMover2D.SetResponse(shape, maxPush: 0.025f, clipVelocity: false);
}
// A bouncy ball in the debris category: the mover walks through it and K kicks it.
void BuildBall(Scene scene)
{
var entity = Spawn(scene, "Ball", new ShapeComponent { Vertices = [Vector2.Zero], Radius = 0.3f, Color = Color.Goldenrod }, ballStart);
ballBody = simulation!.CreateDynamicBody(entity, new Vector3(ballStart, 0));
var def = b2DefaultShapeDef();
def.filter = new B2Filter(CharacterMover2D.DebrisCategory, ulong.MaxValue, 0);
// Bouncy, with a little rolling resistance so it settles.
def.material.restitution = 0.7f;
def.material.rollingResistance = 0.2f;
ShapeFixtureBuilder.AttachShape(Primitive2DModelType.Circle, new Vector2(0.3f, 0.3f), ballBody, def);
entity.Add(new Box2DBodyComponent { BodyId = ballBody });
}
// A kinematic platform in the dynamic category, rigid to the mover: a push limit of ten
// centimetres a step and velocity clipping, so it carries the mover rather than passing through.
void BuildElevator(Scene scene)
{
var start = elevatorBase - new Vector2(0, ElevatorAmplitude);
Vector2[] slab = [new(-2, -0.1f), new(2, -0.1f), new(2, 0.1f), new(-2, 0.1f)];
var entity = Spawn(scene, "Elevator", new ShapeComponent { Vertices = [.. slab], Color = Color.Plum }, start);
var body = simulation!.CreateKinematicBody(entity, new Vector3(start, 0));
var def = b2DefaultShapeDef();
def.filter = new B2Filter(CharacterMover2D.DynamicCategory, ulong.MaxValue, 0);
var box = b2MakeBox(2f, 0.1f);
var shape = b2CreatePolygonShape(body, in def, in box);
// Ten centimetres a step and a clipped velocity: rigid, so it carries rather than yields.
CharacterMover2D.SetResponse(shape, maxPush: 0.1f, clipVelocity: true);
entity.Add(new Box2DBodyComponent { BodyId = body });
simulation.RegisterSimulationUpdate(new Elevator(body, elevatorBase, ElevatorAmplitude));
}
// The character: an entity with a capsule outline, and the mover that drives it. Registered with
// the simulation, the mover steps itself after every fixed physics step.
void BuildHero(Scene scene)
{
heroShape = new ShapeComponent { Vertices = [new(0, -0.5f), new(0, 0.5f)], Radius = 0.3f, Color = Color.Orange };
var hero = Spawn(scene, "Hero", heroShape, spawn);
mover = new CharacterMover2D(spawn) { Entity = hero };
simulation!.RegisterSimulationUpdate(mover);
// The camera follows the hero, looking a little above it.
cameraController!.FollowTarget = hero;
cameraController.FollowOffset = new Vector3(0, 2, 0);
}
// An entity with an outline to draw, placed in the scene; the body that moves it comes next.
Entity Spawn(Scene scene, string name, ShapeComponent shape, Vector2 position)
{
var entity = new Entity(name) { shape };
entity.Transform.Position = new Vector3(position, 0);
entity.Scene = scene;
return entity;
}
void SetupMenu()
{
pogoMenu = new DebugTextDropdown
{
Title = "Pogo shape",
ToggleKey = Keys.Z,
TitleColor = Color.Yellow,
SelectedIndex = 2,
Items =
[
new(Keys.D1, "point - cheapest, slips off ledges soonest", () => mover!.PogoShape = PogoShape.Point),
new(Keys.D2, "circle - smooths small steps", () => mover!.PogoShape = PogoShape.Circle),
new(Keys.D3, "segment - stands on ledges the point misses", () => mover!.PogoShape = PogoShape.Segment),
],
};
}
void Update(Scene scene, GameTime time)
{
simulation?.Update(time.Elapsed);
var input = game.Input;
// While the pogo menu is open the number keys are its, and nothing else runs.
if (pogoMenu is not null && pogoMenu.Update(input)) return;
if (mover is null) return;
// The first press of A or D hands the character to the player for good.
if (input.IsKeyDown(Keys.A) || input.IsKeyDown(Keys.D))
autoWalk = false;
mover.Throttle = autoWalk ? 1 : (input.IsKeyDown(Keys.D) ? 1 : 0) - (input.IsKeyDown(Keys.A) ? 1 : 0);
// Walking by itself, the character jumps whenever a wall plane is in its way - the pillar, the
// stairs up to the bridge, the far ground's steps - so the whole course plays through untouched.
if (autoWalk && mover.IsOnGround && BlockedByWall())
mover.Jump();
// The keys: jump, kick, contacts on and off, reset.
if (input.IsKeyPressed(Keys.Space))
mover.Jump();
if (input.IsKeyPressed(Keys.K))
Kick();
if (input.IsKeyPressed(Keys.G))
showContacts = !showContacts;
if (input.IsKeyPressed(Keys.R))
Reset();
kickFlash = MathF.Max(0, kickFlash - (float)time.Elapsed.TotalSeconds);
Draw();
}
// The sample's kick: an overlap just below the feet, restricted to debris, and an impulse away
// from the mover on whatever dynamic body it finds.
void Kick()
{
var centre = KickCentre();
var filter = new B2QueryFilter(CharacterMover2D.MoverCategory, CharacterMover2D.DebrisCategory);
foreach (var body in simulation!.OverlapCircle(centre, 0.5f, filter))
{
if (b2Body_GetType(body) != B2BodyType.b2_dynamicBody) continue;
// Away from the mover and up, so the ball lifts rather than skids.
var target = b2Body_GetWorldCenterOfMass(body);
var direction = b2Normalize(target - new B2Vec2(mover!.Position.X, mover.Position.Y));
b2Body_ApplyLinearImpulseToCenter(body, new B2Vec2(2f * direction.X, 2f), true);
}
kickFlash = 0.2f;
}
Vector2 KickCentre() => mover!.Bottom - new Vector2(0, 3f * mover.Radius);
// A contact plane facing mostly sideways, against the direction of travel, is a wall.
bool BlockedByWall()
{
foreach (var plane in mover!.Planes)
{
if (MathF.Abs(plane.plane.normal.X) > 0.7f && MathF.Sign(plane.plane.normal.X) != MathF.Sign(mover.Throttle))
return true;
}
return false;
}
void Reset()
{
// The character back at its spawn, the ball at rest where it started, and the auto-walk on again.
mover!.Teleport(spawn);
b2Body_SetTransform(ballBody, new B2Vec2(ballStart.X, ballStart.Y), b2Rot_identity);
b2Body_SetLinearVelocity(ballBody, b2Vec2_zero);
b2Body_SetAngularVelocity(ballBody, 0f);
autoWalk = true;
}
void Draw()
{
if (shapeBatch is null || mover is null) return;
// The level outlines, closed, in the samples' pale green.
foreach (var outline in terrain)
{
for (var i = 0; i < outline.Length; i++)
shapeBatch.DrawPixelLine(new Vector3(outline[i], 0), new Vector3(outline[(i + 1) % outline.Length], 0), 2f, Color.PaleGreen);
}
// Orange on the ground, aquamarine in the air: the pogo's verdict, painted on the capsule.
heroShape!.Color = mover.IsOnGround ? Color.Orange : Color.Aquamarine;
// The contact planes the mover collected: a dot on the capsule's surface and the normal.
foreach (var plane in mover.Planes)
{
var normal = new Vector2(plane.plane.normal.X, plane.plane.normal.Y);
var p1 = mover.Position + (plane.plane.offset - mover.Radius) * normal;
var p2 = p1 + 0.1f * normal;
shapeBatch.DrawPixelDisc(new Vector3(p1, 0), 4f, Color.Yellow);
shapeBatch.DrawPixelLine(new Vector3(p1, 0), new Vector3(p2, 0), 2f, Color.Yellow);
}
// The pogo: grey while it reaches nothing, plum when it stands on something.
var pogoColor = mover.PogoHit ? Color.Plum : Color.Gray;
var end = mover.PogoEnd;
shapeBatch.DrawPixelLine(new Vector3(mover.PogoOrigin, 0), new Vector3(end, 0), 2f, pogoColor);
switch (mover.PogoShape)
{
case PogoShape.Point:
shapeBatch.DrawPixelDisc(new Vector3(end, 0), 5f, pogoColor);
break;
case PogoShape.Circle:
shapeBatch.DrawRing(new Vector3(end, 0), Vector3.UnitZ, 0.5f * mover.Radius, pogoColor);
break;
default:
var half = new Vector2(0.75f * mover.Radius, 0);
shapeBatch.DrawPixelLine(new Vector3(end - half, 0), new Vector3(end + half, 0), 2f, pogoColor);
break;
}
// Velocity, drawn as a line from the centre.
shapeBatch.DrawPixelLine(new Vector3(mover.Position, 0), new Vector3(mover.Position + mover.Velocity, 0), 2f, Color.Purple);
if (kickFlash > 0)
shapeBatch.DrawRing(new Vector3(KickCentre(), 0), Vector3.UnitZ, 0.5f, Color.Goldenrod);
if (showContacts && simulation is not null)
debugDraw?.Draw(simulation);
}
void AddInstructions()
{
var overlay = DebugOverlay.GetOrCreate(game);
// The keys first, the live numbers under them.
overlay.AddSection("Mover", () =>
{
var m = mover!;
return
[
new(["A", "D"], "Walk", Color.Yellow),
new("Space", "Jump", Color.Yellow),
new("K", "Kick the ball", Color.Yellow),
new("G", $"Box2D contact points {(showContacts ? "on" : "off")}", Color.Yellow),
new("R", "Reset", Color.Yellow),
new("Left mouse", "Throw things at the character", Color.Yellow),
.. pogoMenu?.GetLines() ?? [],
new(""),
new($"Position {m.Position.X:0.00}, {m.Position.Y:0.00}, {(m.IsOnGround ? "on ground" : "in the air")}", Color.LightGreen),
new($"Velocity {m.Velocity.X:0.00}, {m.Velocity.Y:0.00}", Color.LightGreen),
new($"Planes {m.Planes.Length}, solver iterations {m.IterationsLastStep}"),
];
});
}
// The kinematic elevator: a cosine ride set as a target transform each fixed step, so it carries
// a velocity the mover's planes and the contact solver both feel.
public sealed class Elevator(B2BodyId body, Vector2 origin, float amplitude) : IBox2DSimulationUpdate
{
private float _time;
public void SimulationUpdate(Box2DSimulation simulation, float deltaTime)
{
_time += deltaTime;
var y = amplitude * MathF.Cos(_time + MathF.PI) + origin.Y;
var target = new B2Transform(new B2Vec2(origin.X, y), b2Rot_identity);
b2Body_SetTargetTransform(body, in target, deltaTime, true);
}
public void AfterSimulationUpdate(Box2DSimulation simulation, float deltaTime) { }
}
// The two grounds of the samples' Mover scene, as drawn in Inkscape.
static partial class Program
{
public const string GroundPath1 =
"M 2.6458333,201.08333 H 293.68751 v -47.625 h -2.64584 l -10.58333,7.9375 -13.22916,7.9375 -13.24648,5.29167 "
+ "-31.73269,7.9375 -21.16667,2.64583 -23.8125,10.58333 H 142.875 v -5.29167 h -5.29166 v 5.29167 H 119.0625 v "
+ "-2.64583 h -2.64583 v -2.64584 h -2.64584 v -2.64583 H 111.125 v -2.64583 H 84.666668 v -2.64583 h -5.291666 v "
+ "-2.64584 h -5.291667 v -2.64583 H 68.791668 V 174.625 h -5.291666 v -2.64584 H 52.916669 L 39.6875,177.27083 H "
+ "34.395833 L 23.8125,185.20833 H 15.875 L 5.2916669,187.85416 V 153.45833 H 2.6458333 v 47.625";
public const string GroundPath2 =
"M 2.6458333,201.08333 H 293.68751 l 0,-23.8125 h -23.8125 l 21.16667,21.16667 h -23.8125 l -39.68751,-13.22917 "
+ "-26.45833,7.9375 -23.8125,2.64583 h -13.22917 l -0.0575,2.64584 h -5.29166 v -2.64583 l -7.86855,-1e-5 "
+ "-0.0114,-2.64583 h -2.64583 l -2.64583,2.64584 h -7.9375 l -2.64584,2.64583 -2.58891,-2.64584 h -13.28609 v "
+ "-2.64583 h -2.64583 v -2.64584 l -5.29167,1e-5 v -2.64583 h -2.64583 v -2.64583 l -5.29167,-1e-5 v -2.64583 h "
+ "-2.64583 v -2.64584 h -5.291667 v -2.64583 H 92.60417 V 174.625 h -5.291667 v -2.64584 l -34.395835,1e-5 "
+ "-7.9375,-2.64584 -7.9375,-2.64583 -5.291667,-5.29167 H 21.166667 L 13.229167,158.75 5.2916668,153.45833 H "
+ "2.6458334 l -10e-8,47.625";
}