Junkyard (Box2D)
A faithful replica of the Box2D.NET BenchmarkJunkyard sample: 8,000 small five-sided rocks rain into a walled yard and a kinematic plow sweeps back and forth through the pile, driven by a target transform once per fixed step. Rendering works exactly like the Box2D testbed: no meshes, materials or entities - every shape is submitted each frame to the toolkit's ShapeBatch, whose shader (a port of the testbed's solid_polygon shader) draws them all in one instanced call with the 60%-alpha fill and pixel-constant border computed per fragment. Body states show as the testbed's colours - pink awake, salmon fast-movers, gray sleepers.
The Program.cs file shows how to:
- Replicating a Box2D testbed benchmark scene in Stride, rendering included
- Immediate-mode shape drawing with ShapeBatch - no meshes, materials or entities
- An SDF shader computing fill, border and transparency per fragment, stable under any zoom
- Entity-less physics bodies as the single source of truth, read directly each frame
- Driving a kinematic body with SetTargetTransform once per fixed step
- Hooking per-fixed-step logic through IBox2DSimulationUpdate
- Colour-coding awake, fast and sleeping bodies straight from body state
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.Scripts.Utilities;
using Stride.CommunityToolkit.Shapes;
using Stride.CommunityToolkit.Windows;
using Stride.Core.Mathematics;
using Stride.Engine;
using Stride.Games;
using static Box2D.NET.B2Bodies;
using static Box2D.NET.B2Geometries;
using static Box2D.NET.B2MathFunction;
using static Box2D.NET.B2Shapes;
// A faithful replica of the Box2D.NET sample Benchmarks/BenchmarkJunkyard (MIT, (c) 2022 Erin Catto,
// (c) 2025 Choi Ikpil): a walled yard whose
// floor and walls are rows of overlapping static squares, 8,000 small five-sided "rocks" raining
// into it, and a kinematic pusher plowing back and forth through the pile at x = 60*sin(0.2t).
//
// Rendering works exactly like the Box2D testbed: no meshes, no materials, no entities - every
// shape is submitted each frame to the toolkit's ShapeBatch, whose shader (a port of the
// testbed's solid_polygon shader) draws all of them in one instanced call, computing the 60%-alpha
// fill and the pixel-constant border per fragment. Zoom and resize cost nothing; overlapping
// shapes blend through each other; body states show as the testbed's colours - pale green statics,
// pink awake, salmon fast-movers, gray sleepers, royal blue pusher - on its dark gray background.
// --- the sample's numbers, verbatim
const float GridSize = 1.0f;
const int ColumnCount = 200;
const int RowCount = 40;
const float Radius = 0.25f;
const float YStart = 15.0f;
// The testbed paints continuous-collision candidates salmon: bodies sweeping more than roughly half
// their extent in one step. Approximated here as speed > 0.5 * radius / timestep.
const float FastSpeed = 0.5f * Radius * 60f;
// --- the testbed palette (b2HexColor values used by b2World_Draw, and the samples' GL clear colour)
var paleGreen = new Color(0x98, 0xFB, 0x98);
var pink = new Color(0xFF, 0xC0, 0xCB);
var salmon = new Color(0xFA, 0x80, 0x72);
var gray = new Color(0x80, 0x80, 0x80);
var royalBlue = new Color(0x41, 0x69, 0xE1);
var background = new Color(0.2f, 0.2f, 0.2f);
Box2DSimulation? simulation = null;
ShapeBatch? shapeBatch = null;
// Bodies are entity-less: physics is the single source of truth and the debug draw reads it
// directly every frame, exactly like the testbed
List<B2BodyId> rockIds = [];
B2BodyId pusherId = default;
// The five-sided rock outline: the sample places five points on a circle by the Fibonacci sphere
// algorithm and takes their convex hull. The hull sorts them; the shader wants them sorted too.
var pentagon = FibonacciPentagon(Radius);
// Static square outlines, reused for every floor and wall submission
var floorSquare = RectangleVertices(0.55f * GridSize, 0.5f * GridSize);
var wallSquare = RectangleVertices(0.5f * GridSize, 0.55f * GridSize);
// The pusher plow: a 4 x 8 box whose shape sits 4 units above the body origin
Vector2[] pusherBoxVertices = [new(-2, 0), new(2, 0), new(2, 8), new(-2, 8)];
// Per-monitor DPI awareness, so a 150% display gets a sharp window rather than a stretched one;
// it has to happen before the window exists
WindowsDpiManager.EnablePerMonitorV2();
using var game = new Game();
game.Run(start: Start, update: Update);
simulation?.Dispose();
void Start(Scene rootScene)
{
game.Window.AllowUserResizing = true;
game.Window.Title = "Junkyard Box2D Example - Stride Community Toolkit";
game.SetupBase2D(clearColor: background);
game.Add2DCameraController();
game.AddProfiler();
// The sample's viewport: camera centered on (8, 25), zoom 60 - which in the testbed means the
// visible world is 60 units tall. The camera controller adopts this size and scales it when the
// mouse wheel zooms; the shader keeps borders pixel-constant at any zoom on its own.
var camera = rootScene.GetCamera() ?? throw new InvalidOperationException("Camera not found in scene");
camera.Entity.Transform.Position = new Vector3(8, 25, 50);
camera.OrthographicSize = 60;
shapeBatch = game.AddShapeBatch();
shapeBatch.BorderWidth = 1f;
shapeBatch.Fill.Alpha = 0.4f;
simulation = new Box2DSimulation();
// The testbed steps the world exactly once per rendered frame - no catch-up. This scene is the
// heaviest benchmark in the sample suite, and letting the accumulator run three catch-up steps
// per frame would only deepen the slow motion it plays in on a loaded machine.
simulation.MaxStepsPerFrame = 1;
// Left mouse picks a rock up and throws it - the one interaction the replica adds to the testbed
game.GetCameraEntity().Add(new Grabber2DScript { Simulation = simulation });
DebugOverlay.GetOrCreate(game).AddSection("Junkyard", static () =>
[
new("Left mouse", "Pick a rock up, carry it, throw it", Color.Yellow),
]);
CreateGround();
SpawnRocks();
CreatePusher();
}
/// <summary>
/// One static body carrying every floor and wall shape: 161 slightly overlapping squares across the
/// bottom and 50 up each side wall at x = -80 and x = +80, exactly as the sample builds them.
/// </summary>
void CreateGround()
{
var groundId = simulation!.CreateStaticBody(Vector3.Zero);
var shapeDef = ShapeFixtureBuilder.CreateDefaultShapeDef();
var y = 0.0f;
var x = -80.0f * GridSize;
for (var i = 0; i < 161; ++i)
{
var box = b2MakeOffsetBox(0.55f * GridSize, 0.5f * GridSize, new B2Vec2(x, y), b2Rot_identity);
b2CreatePolygonShape(groundId, in shapeDef, in box);
x += GridSize;
}
y = GridSize;
x = -80.0f * GridSize;
for (var i = 0; i < 50; ++i)
{
var box = b2MakeOffsetBox(0.5f * GridSize, 0.55f * GridSize, new B2Vec2(x, y), b2Rot_identity);
b2CreatePolygonShape(groundId, in shapeDef, in box);
y += GridSize;
}
y = GridSize;
x = 80.0f * GridSize;
for (var i = 0; i < 50; ++i)
{
var box = b2MakeOffsetBox(0.5f * GridSize, 0.55f * GridSize, new B2Vec2(x, y), b2Rot_identity);
b2CreatePolygonShape(groundId, in shapeDef, in box);
y += GridSize;
}
}
/// <summary>
/// The junk: 200 columns x 40 rows of five-sided rocks stacked from y = 15 upward, each column
/// zig-zagged sideways by the alternating 0.1 offset, exactly as the sample spawns them.
/// </summary>
void SpawnRocks()
{
var side = -0.1f;
for (var i = 0; i < ColumnCount; ++i)
{
var x = 1.5f * (2.0f * i - ColumnCount) * Radius;
for (var j = 0; j < RowCount; ++j)
{
var y = 4.0f * j * Radius + YStart;
var position = new Vector3(x + side, y, 0);
side = -side;
var bodyId = simulation!.CreateDynamicBody(position);
ShapeFixtureBuilder.AttachPolygon(pentagon, bodyId);
rockIds.Add(bodyId);
}
}
}
/// <summary>
/// The pusher: a kinematic 4 x 8 plow whose shape sits 4 units above its body origin. Its sweep is
/// driven per fixed step by <see cref="PusherDriver"/> through the simulation update hook, exactly
/// like the sample's StepJunkyard.
/// </summary>
void CreatePusher()
{
pusherId = simulation!.CreateKinematicBody(Vector3.Zero);
var shapeDef = ShapeFixtureBuilder.CreateDefaultShapeDef();
var pusherBox = b2MakeOffsetBox(2.0f, 4.0f, new B2Vec2(0.0f, 4.0f), b2Rot_identity);
b2CreatePolygonShape(pusherId, in shapeDef, in pusherBox);
simulation.RegisterSimulationUpdate(new PusherDriver(pusherId));
}
void Update(Scene rootScene, GameTime time)
{
// Box2D is stepped by hand: fixed-timestep accumulation and the pusher drive happen inside
simulation?.Update(time.Elapsed);
SubmitShapes();
}
/// <summary>
/// Submits every shape for this frame straight from the physics world, in the testbed's draw order:
/// the static yard first, then the rocks coloured by their state, the pusher last on top.
/// </summary>
void SubmitShapes()
{
if (shapeBatch is null) return;
var x = -80.0f * GridSize;
for (var i = 0; i < 161; ++i)
{
shapeBatch.DrawSolidPolygon(floorSquare, new Vector2(x, 0f), 0f, paleGreen);
x += GridSize;
}
var y = GridSize;
for (var i = 0; i < 50; ++i)
{
shapeBatch.DrawSolidPolygon(wallSquare, new Vector2(-80.0f * GridSize, y), 0f, paleGreen);
shapeBatch.DrawSolidPolygon(wallSquare, new Vector2(80.0f * GridSize, y), 0f, paleGreen);
y += GridSize;
}
foreach (var bodyId in rockIds)
{
var transform = b2Body_GetTransform(bodyId);
Color color;
if (!b2Body_IsAwake(bodyId))
{
color = gray;
}
else
{
var velocity = b2Body_GetLinearVelocity(bodyId);
var fast = velocity.X * velocity.X + velocity.Y * velocity.Y > FastSpeed * FastSpeed;
color = fast ? salmon : pink;
}
SubmitBodyPolygon(pentagon, transform, color);
}
SubmitBodyPolygon(pusherBoxVertices, b2Body_GetTransform(pusherId), royalBlue);
}
void SubmitBodyPolygon(Vector2[] vertices, B2Transform transform, Color color)
{
// The instance transform is the body's, in the same (x, y, cos, sin) form the testbed uses
shapeBatch!.DrawSolidPolygon(vertices, new Vector2(transform.p.X, transform.p.Y), MathF.Atan2(transform.q.s, transform.q.c), color);
}
static Vector2[] RectangleVertices(float halfWidth, float halfHeight) =>
[
new(-halfWidth, -halfHeight),
new(halfWidth, -halfHeight),
new(halfWidth, halfHeight),
new(-halfWidth, halfHeight),
];
/// <summary>
/// The sample's rock outline: five points on a circle of the given radius by the Fibonacci sphere
/// algorithm, sorted by angle so the convex hull and the shader's SDF see them in CCW order.
/// </summary>
static Vector2[] FibonacciPentagon(float radius)
{
var phi = MathF.PI * (MathF.Sqrt(5.0f) - 1.0f);
var points = new Vector2[5];
for (var i = 0; i < 5; ++i)
{
var theta = phi * i;
points[i] = new Vector2(radius * MathF.Cos(theta), radius * MathF.Sin(theta));
}
return [.. points.OrderBy(p => MathF.Atan2(p.Y, p.X))];
}
/// <summary>
/// Drives the kinematic pusher along the sample's sweep, x = 60*sin(0.2t), by setting its target
/// transform once per fixed step - Box2D derives the kinematic velocity from target and time step.
/// </summary>
public sealed class PusherDriver : IBox2DSimulationUpdate
{
private readonly B2BodyId _pusherId;
private int _stepCount;
public PusherDriver(B2BodyId pusherId) => _pusherId = pusherId;
public void SimulationUpdate(Box2DSimulation simulation, float deltaTime)
{
var time = deltaTime * _stepCount;
_stepCount++;
var cosSin = b2ComputeCosSin(0.2f * time);
var target = new B2Transform(new B2Vec2(60.0f * cosSin.sine, 0.0f), b2Rot_identity);
b2Body_SetTargetTransform(_pusherId, in target, deltaTime, true);
}
public void AfterSimulationUpdate(Box2DSimulation simulation, float deltaTime)
{
}
}