Junkyard Playground (Box2D)
The playground sibling of the Junkyard replica: the same walled yard and sweeping plow, built the Stride way - every shape is an entity carrying ShapeComponent and a Box2D body, so components, scripts, events and the camera all join in. Pentagons, circles, capsules and boxes fall and mix freely, switchable at runtime. A sensor gate turns anything passing through gold via the library's sensor events; the mouse picks shapes up and throws them through Grabber2DScript, N drops a new one at the cursor; middle-click makes the camera follow one through the pile.
The Program.cs file shows how to:
- One entity per shape with ShapeComponent - the testbed look through the component system
- Mixing shape kinds freely in one scene, impossible with per-model instanced masters
- Circles and capsules through the SDF shader's rounding radius
- Building the collider from the same vertices as the visual, so they always agree
- Sensor fixtures and the library's sensor events driving gameplay colour
- Picking shapes up and throwing them with Grabber2DScript, a kinematic anchor plus a motor joint
- Camera follow through Basic2DCameraController.FollowTarget
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.Rendering.Overlay;
using Stride.CommunityToolkit.Rendering.Text;
using Stride.CommunityToolkit.Scripts;
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;
// The playground sibling of E06_Box2D_Junkyard: the same walled yard and sweeping plow, built the
// Stride way - every shape is an entity carrying ShapeComponent (the testbed look, no meshes or
// materials) and a Box2D body, so the component system, scripts and camera all join in. Pentagons,
// circles, capsules and boxes, switchable at runtime, fall and mix freely - which the instanced
// stress piles cannot do, as here rendering is one shader batch instead of one master model per shape.
//
// A wheat-coloured sensor gate hangs over the yard: anything passing through turns gold, through
// the library's sensor events. Pick a shape up with the mouse and throw it, press N to drop a new
// one, middle-click a shape and the camera follows it through the pile.
// --- the junkyard's yard, verbatim
const float GridSize = 1.0f;
// --- spawning
const int InitialCount = 600;
const int BatchPerRow = 25;
const float SpawnSpacing = 1.5f;
const float SpawnHeight = 35f;
// The testbed paints continuous-collision candidates salmon: bodies sweeping more than roughly half
// their extent in one step. Approximated for the mixed shape sizes here.
const float FastSpeed = 0.5f * 0.4f * 60f;
// --- the testbed palette, plus gold for shapes inside the sensor gate
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 wheat = new Color(0xF5, 0xDE, 0xB3);
var gold = new Color(0xFF, 0xD7, 0x00);
var background = new Color(0.2f, 0.2f, 0.2f);
ShapeBatch shapeBatch = null!;
CameraComponent camera = null!;
Basic2DCameraController cameraController = null!;
var random = new Random(1);
List<SpawnedShape> shapes = [];
var sensorWatcher = new SensorWatcher();
List<KeyMenu> menus = [];
// The shape catalogue: outline vertices plus the rounding radius the shader applies. A single
// vertex with a radius is a circle, two vertices with a radius a capsule - and the collider is
// attached from the same numbers, so physics and pixels always agree.
ShapeDefinition[] catalogue =
[
new("Pentagon", FibonacciPentagon(0.4f), 0f),
new("Circle", [Vector2.Zero], 0.35f),
new("Capsule", [new(0, -0.25f), new(0, 0.25f)], 0.25f),
new("Box", ShapeComponent.Box(new Vector2(0.7f, 0.7f)), 0f),
];
var currentShape = 0;
using var game = new Game();
using var simulation = new Box2DSimulation();
var sceneSetup = new SceneSetup2D
{
ClearColor = background,
Camera = CameraSetup.Orthographic(position: new Vector3(8, 25, 50), orthographicSize: 60).WithController(),
Ground = null,
Profiler = true,
WindowTitle = "Junkyard Playground Box2D Example - Stride Community Toolkit",
Resizable = true,
DpiAware = true,
};
game.Run(sceneSetup, start: Start, update: Update);
void Start(Scene rootScene)
{
var cameraEntity = game.GetCameraEntity();
cameraController = cameraEntity.Get<Basic2DCameraController>();
camera = cameraEntity.Get<CameraComponent>();
shapeBatch = game.AddShapeBatch();
shapeBatch.BorderWidth = 1f;
shapeBatch.Fill.Alpha = 0.4f;
simulation.RegisterSensorEventHandler(sensorWatcher);
// Pick shapes up and throw them with the left mouse button: a kinematic anchor and a motor joint
// under the cursor, so the held shape still collides with the pile and the plow.
game.AddGrabber2D(simulation);
CreateGround(rootScene);
CreateSensorGate(rootScene);
CreatePusher(rootScene);
SetupMenus();
SpawnBatch(rootScene, InitialCount);
}
/// <summary>
/// The junkyard's yard: one static body carrying 161 slightly overlapping floor squares and 50 wall
/// squares up each side at x = -80 and x = +80 - every square also an entity with a shape component.
/// </summary>
void CreateGround(Scene scene)
{
var groundId = simulation.CreateStaticBody(Vector3.Zero);
var shapeDef = ShapeFixtureBuilder.CreateDefaultShapeDef();
var floorSquare = ShapeComponent.Box(new Vector2(1.1f * GridSize, GridSize));
var wallSquare = ShapeComponent.Box(new Vector2(GridSize, 1.1f * GridSize));
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);
AddStaticSquare(scene, floorSquare, x, y);
x += GridSize;
}
y = GridSize;
for (var i = 0; i < 50; ++i)
{
var leftBox = b2MakeOffsetBox(0.5f * GridSize, 0.55f * GridSize, new B2Vec2(-80.0f * GridSize, y), b2Rot_identity);
b2CreatePolygonShape(groundId, in shapeDef, in leftBox);
AddStaticSquare(scene, wallSquare, -80.0f * GridSize, y);
var rightBox = b2MakeOffsetBox(0.5f * GridSize, 0.55f * GridSize, new B2Vec2(80.0f * GridSize, y), b2Rot_identity);
b2CreatePolygonShape(groundId, in shapeDef, in rightBox);
AddStaticSquare(scene, wallSquare, 80.0f * GridSize, y);
y += GridSize;
}
}
void AddStaticSquare(Scene scene, Vector2[] vertices, float x, float y)
{
var entity = new Entity("StaticSquare") { new ShapeComponent { Vertices = [.. vertices], Color = paleGreen } };
entity.Transform.Position = new Vector3(x, y, 0);
entity.Scene = scene;
}
/// <summary>
/// A sensor gate hanging over the yard: a static sensor fixture that reports overlaps through the
/// library's sensor events, so anything inside it turns gold. Sensors detect but never collide.
/// </summary>
void CreateSensorGate(Scene scene)
{
var gateVertices = ShapeComponent.Box(new Vector2(16f, 6f));
var entity = new Entity("SensorGate") { new ShapeComponent { Vertices = [.. gateVertices], Color = wheat } };
entity.Transform.Position = new Vector3(0, 22, 0);
entity.Scene = scene;
var bodyId = simulation.CreateStaticBody(entity, new Vector3(0, 22, 0));
var sensorDef = ShapeFixtureBuilder.CreateCustomShapeDef(1f, 0.6f, 0f, isSensor: true);
sensorDef.enableSensorEvents = true;
ShapeFixtureBuilder.AttachPolygon(gateVertices, bodyId, sensorDef);
}
/// <summary>
/// The junkyard's kinematic plow, sweeping x = 60*sin(0.2t) - here it is also just an entity with a
/// shape component, carried by the body-to-entity transform sync.
/// </summary>
void CreatePusher(Scene scene)
{
var entity = new Entity("Pusher") { new ShapeComponent { Vertices = [new(-2, 0), new(2, 0), new(2, 8), new(-2, 8)], Color = royalBlue } };
entity.Scene = scene;
var pusherId = simulation.CreateKinematicBody(entity, 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 SetupMenus()
{
menus =
[
new KeyMenu
{
Title = "Shape",
ToggleKey = Keys.C,
TitleColor = Color.Yellow,
SelectedIndex = currentShape,
Items = [.. catalogue.Select((definition, index) => new KeyMenuItem(
(Keys)(Keys.D1 + index), definition.Name, () => currentShape = index))]
},
];
TextOverlay.GetOrCreate(game).AddSection("Playground", BuildOverlayLines);
}
void SpawnBatch(Scene scene, int count)
{
var rows = Math.Max(1, count / BatchPerRow);
for (var i = 0; i < rows; i++)
{
for (var j = 0; j < BatchPerRow; j++)
{
Spawn(scene, catalogue[currentShape], new Vector2((j - BatchPerRow / 2f) * SpawnSpacing + Jitter(), SpawnHeight + i * SpawnSpacing + Jitter()));
}
}
float Jitter() => (random.NextSingle() - 0.5f) * 0.1f;
}
/// <summary>
/// One shape = one entity: the component draws it, the body moves it, and the same numbers build
/// both the visual outline and the collider.
/// </summary>
void Spawn(Scene scene, ShapeDefinition definition, Vector2 position)
{
var component = new ShapeComponent { Vertices = [.. definition.Vertices], Radius = definition.Radius, Color = pink };
var entity = new Entity("Shape") { component };
var bodyId = simulation.CreateDynamicBody(entity, new Vector3(position.X, position.Y, 0));
var shapeDef = ShapeFixtureBuilder.CreateDefaultShapeDef();
shapeDef.enableSensorEvents = true;
AttachCollider(definition, bodyId, shapeDef);
entity.Add(new Box2DBodyComponent { BodyId = bodyId });
entity.Transform.Position = new Vector3(position.X, position.Y, 0);
entity.Scene = scene;
shapes.Add(new SpawnedShape(entity, bodyId, component));
}
void AttachCollider(ShapeDefinition definition, B2BodyId bodyId, B2ShapeDef shapeDef)
{
if (definition.Vertices.Length == 1)
{
// A circle: the rounding radius is the whole shape
ShapeFixtureBuilder.AttachShape(Primitive2D.Circle(definition.Radius), bodyId, shapeDef);
}
else if (definition.Vertices.Length == 2)
{
// A capsule: two segment endpoints plus the radius; total height = segment + both caps
var halfSegment = MathF.Abs(definition.Vertices[1].Y - definition.Vertices[0].Y) * 0.5f;
ShapeFixtureBuilder.AttachShape(Primitive2D.Capsule(definition.Radius, 2f * (halfSegment + definition.Radius)), bodyId, shapeDef);
}
else
{
ShapeFixtureBuilder.AttachPolygon(definition.Vertices, bodyId, shapeDef);
}
}
void Update(Scene rootScene, GameTime time)
{
simulation.Update(time.Elapsed);
UpdateShapeColors();
HandleInput(rootScene);
}
/// <summary>
/// The testbed's body colouring, driven straight into each entity's component: gold inside the
/// sensor gate, gray asleep, salmon fast, pink otherwise.
/// </summary>
void UpdateShapeColors()
{
foreach (var shape in shapes)
{
shape.Component.Color = BodyColor(shape);
}
}
Color BodyColor(SpawnedShape shape)
{
if (sensorWatcher.Contains(shape.Entity)) return gold;
if (!b2Body_IsAwake(shape.BodyId)) return gray;
var velocity = b2Body_GetLinearVelocity(shape.BodyId);
return velocity.X * velocity.X + velocity.Y * velocity.Y > FastSpeed * FastSpeed ? salmon : pink;
}
void HandleInput(Scene rootScene)
{
if (!game.Input.HasKeyboard) return;
foreach (var menu in menus)
{
if (menu.Update(game.Input)) return;
}
if (game.Input.IsKeyPressed(Keys.Space)) SpawnBatch(rootScene, InitialCount / 2);
if (game.Input.IsKeyPressed(Keys.X)) ClearShapes();
if (game.Input.IsKeyPressed(Keys.Escape)) cameraController.FollowTarget = null;
if (!game.Input.HasMouse) return;
// The left button belongs to the grabber; N drops one of the selected kind at the cursor.
if (game.Input.IsKeyPressed(Keys.N) && camera.ScreenToWorldPointOnXYPlane(game.Input.MousePosition) is { } dropPoint)
{
Spawn(rootScene, catalogue[currentShape], dropPoint);
}
if (game.Input.IsMouseButtonPressed(MouseButton.Middle))
{
var world = camera.ScreenToWorldPointOnXYPlane(game.Input.MousePosition);
if (world is { } point && simulation.OverlapPoint(point) is { } bodyId)
{
// The camera controller tracks the entity until Escape clears FollowTarget
cameraController.FollowTarget = simulation.GetEntity(bodyId);
}
}
}
void ClearShapes()
{
foreach (var shape in shapes)
{
simulation.RemoveBody(shape.Entity);
shape.Entity.Scene = null;
}
shapes.Clear();
sensorWatcher.Clear();
}
IReadOnlyList<TextElement> BuildOverlayLines()
{
var asleep = 0;
foreach (var shape in shapes)
{
if (!b2Body_IsAwake(shape.BodyId)) asleep++;
}
List<TextElement> lines =
[
new("Left mouse", "Pick a shape up, carry it, throw it", Color.Yellow),
new("Middle click", "Follow a shape", Color.Yellow),
new("Escape", "Stop following", Color.Yellow),
new("N", "Drop a new one at the cursor", Color.Yellow),
new("Space", $"Spawn {InitialCount / 2} more", Color.Yellow),
new("X", "Clear", Color.Yellow),
];
foreach (var menu in menus)
{
lines.AddRange(menu.GetLines());
}
lines.Add(new(string.Empty));
lines.Add(new($"{shapes.Count:N0} shapes as entities, one shader batch", Color.LightGreen));
lines.Add(new($"{shapes.Count - asleep:N0} awake, {asleep:N0} asleep, {sensorWatcher.Count} in the gate", Color.MediumSeaGreen));
return lines;
}
/// <summary>
/// The junkyard's rock outline: five points on a circle 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>One entry of the shape catalogue: a name for the menu, the outline, and the rounding radius.</summary>
public sealed record ShapeDefinition(string Name, Vector2[] Vertices, float Radius);
/// <summary>One spawned shape: the entity, its body, and the component whose colour tracks the body state.</summary>
public sealed record SpawnedShape(Entity Entity, B2BodyId BodyId, ShapeComponent Component);
/// <summary>
/// Tracks which entities are inside the sensor gate through the library's sensor events. Begin adds
/// the visitor, End removes it; overlapping multiple sensor shapes is counted, not toggled.
/// </summary>
public sealed class SensorWatcher : ISensorEventHandler
{
private readonly Dictionary<Entity, int> _inside = [];
public int Count => _inside.Count;
public bool Contains(Entity entity) => _inside.ContainsKey(entity);
public void Clear() => _inside.Clear();
public void OnSensorEvent(SensorEventData eventData)
{
if (eventData.Type == SensorEventType.BeginTouch)
{
_inside[eventData.VisitorEntity] = _inside.GetValueOrDefault(eventData.VisitorEntity) + 1;
}
else if (_inside.TryGetValue(eventData.VisitorEntity, out var depth))
{
if (depth <= 1)
{
_inside.Remove(eventData.VisitorEntity);
}
else
{
_inside[eventData.VisitorEntity] = depth - 1;
}
}
}
}
/// <summary>
/// Drives the kinematic pusher along the junkyard'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(B2BodyId pusherId) : IBox2DSimulationUpdate
{
private int _stepCount;
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);
}
}