2D Simulation API
The 2D simulation API creates standalone Box2D worlds that you own and step yourself, outside the world organizer. Use it for an authoritative match simulation, a prediction or rollback scratch world, a headless bot trainer, or a test. Each world is independent: it has its own bodies, contacts and listener, and nothing else in the server ticks it.
The organizer's 2D worlds (entities, SpawnDynamicObject, visibility) are unaffected. This page covers the lower-level API underneath, which you call directly.
Creating a World
using System.Numerics;
using Altruist.Physx; // PhysxWorldEngine2D, IPhysxWorldEngine2D
using Altruist.Physx.Contracts; // PhysxBodyType, PhysxForce
using Altruist.Physx.TwoD; // body, fixture, shape, contact types
var world = PhysxWorldEngine2D.Create(new PhysxWorldSettings2D
{
Gravity = new Vector2(0, -10),
FixedDeltaTime = 1f / 60f,
VelocityIterations = 8,
PositionIterations = 3,
});
for (var tick = 0; tick < 600; tick++)
world.Step(world.FixedDeltaTime);
world.Dispose();
PhysxWorldEngine2D.Create is a static entry point and needs no DI. The same worlds are also available through the injectable IPhysxWorldEngineFactory2D, registered in every environment mode (a 3D server can run 2D match simulations too):
public sealed class MatchFactory(IPhysxWorldEngineFactory2D physics)
{
public IPhysxWorldEngine2D NewWorld() =>
physics.Create(new PhysxWorldSettings2D { Gravity = Vector2.Zero });
}
PhysxWorldSettings2D
| Property | Default | Meaning |
|---|---|---|
Gravity | (0, 0) | World gravity in units per second squared |
FixedDeltaTime | 1/60 | The fixed step, exposed as world.FixedDeltaTime. Used for stepping only with MaxSubSteps above zero |
VelocityIterations | 8 | Velocity solver iterations per step |
PositionIterations | 3 | Position solver iterations per step |
MaxSubSteps | 0 | Fixed stepping: 0 steps by the deltaTime you pass; above zero, Step runs whole steps of FixedDeltaTime, at most this many per call |
More iterations give stiffer stacks and fewer overlaps, and cost more time per step.
Stepping
By default (MaxSubSteps = 0) Step(deltaTime) advances the world by exactly the value you pass, in one step. Pass the same fixed value every step: a variable step changes the results.
With MaxSubSteps above zero the world steps itself at FixedDeltaTime: Step(deltaTime) adds deltaTime to an accumulator and runs whole steps of FixedDeltaTime while it lasts, at most MaxSubSteps per call. The rest carries over to the next call; time beyond MaxSubSteps steps is dropped, so a long hitch does not snowball into ever more steps. Use it when you feed the measured frame time:
var world = PhysxWorldEngine2D.Create(new PhysxWorldSettings2D
{
Gravity = new Vector2(0, -10),
FixedDeltaTime = 1f / 60f,
MaxSubSteps = 4,
});
world.Step(1f / 30f); // two steps of 1/60
world.Step(1f / 120f); // nothing yet: half a step accumulated
world.Step(1f / 120f); // one step
A world without bodies skips the step.
Bodies
CreateBody creates a body that is already part of the world. You do not call AddBody afterwards.
var crate = world.CreateBody(new PhysxBodyDef2D
{
Type = PhysxBodyType.Dynamic,
Position = new Vector2(0, 5),
Angle = 0f,
UserData = myEntity,
});
PhysxBodyDef2D
| Property | Default | Meaning |
|---|---|---|
Type | Static | Static, Dynamic or Kinematic. Static is the default, as in Box2D: set Dynamic for a body that moves |
Position | (0, 0) | World position |
Angle | 0 | Rotation in radians |
Bullet | false | Continuous collision against other dynamic bodies, for fast movers such as a ball |
AllowSleep | true | The body may sleep when it comes to rest |
FixedRotation | false | The body never rotates |
LinearDamping | 0 | Linear velocity damping |
AngularDamping | 0 | Angular velocity damping |
GravityScale | 1 | Multiplier of the world gravity for this body |
UserData | null | Game data carried by the body |
Id | null | Body id; a new one is generated when null |
new PhysxBodyDef2D { ... }, new PhysxBodyDef2D() and default(PhysxBodyDef2D) all start from the defaults in this table.
To remove a body, call world.RemoveBody(body). world.Bodies lists the world's bodies.
Body Members
IPhysxBody2D gives you the usual rigid-body surface:
| Member | Meaning |
|---|---|
Position, RotationZ | World position and angle in radians. Setting either teleports the body |
LinearVelocity, AngularVelocityZ | Velocities |
Mass | Mass derived from the fixtures. Setting it scales mass and rotational inertia so the body weighs exactly this much. Set it after adding fixtures |
ApplyForce(PhysxForce) | PhysxForce.Force2D, Impulse2D, Torque2D, LinearVelocity2D, AngularVelocity2D, applied at the center of mass |
UserData | Game data (a tag, the owning entity) |
IsAwake | A sleeping body is skipped by the solver until something wakes it. Set it to true to wake the body |
IsEnabled | A disabled body takes no part in the simulation: no contacts, no motion |
WorldCenter | Center of mass in world coordinates |
SetTransform(position, angle) | Moves and rotates the body in one call |
GetWorldVector(local) / GetLocalVector(world) | Converts a direction between body and world space |
GetWorldPoint(local) / GetLocalPoint(world) | Converts a point between body and world space |
// Push a car forward along its own +X axis.
var forward = car.GetWorldVector(new Vector2(1, 0));
car.ApplyForce(PhysxForce.Impulse2D(forward * 2f));
// Where is the ball relative to the car's nose?
var local = car.GetLocalPoint(ball.Position);
bool ballInFront = local.X > 0;
Rotation2D
Rotation2D (namespace Altruist.TwoD.Numerics) does the same conversion for any angle, without a body:
var r = Rotation2D.FromDegrees(90); // or FromRadians(...)
var world = r.Rotate(new Vector2(1, 0)); // (0, 1): local to world, counter-clockwise
var local = r.Unrotate(world); // (1, 0): world to local
Fixtures and Shapes
A fixture attaches a shape to a body together with its material and collision filter. Shapes are in body-local coordinates.
world.CreateFixture(crate, new PhysxFixtureDef2D
{
Shape = PhysxShape2D.Box(0.5f, 0.5f),
Density = 1f,
Friction = 0.4f,
Restitution = 0.2f,
});
Shapes
| Factory | Shape |
|---|---|
PhysxShape2D.Circle(radius, center = default) | A circle, optionally offset from the body origin |
PhysxShape2D.Box(halfWidth, halfHeight, center = default, angle = 0) | A rectangle from half extents, optionally offset and rotated (radians) |
PhysxShape2D.Polygon(vertices) | A convex polygon of 3 to 8 vertices; the engine computes the hull |
PhysxShape2D.Chain(vertices, loop) | A chain of edges; loop: true closes it |
PhysxShape2D.Edge(a, b) | A single two-sided edge |
Chains are one-sided. A loop wound clockwise collides on its inside, which makes it the simplest way to build the walls of an arena:
var walls = world.CreateBody(new PhysxBodyDef2D { Type = PhysxBodyType.Static });
world.CreateFixture(walls, new PhysxFixtureDef2D
{
// Up the left side, across the top, down the right side: clockwise.
Shape = PhysxShape2D.Chain(new[]
{
new Vector2(-5, 0), new Vector2(-5, 10), new Vector2(5, 10), new Vector2(5, 0),
}, loop: true),
});
Chains and edges belong on static bodies. Give dynamic bodies circles, boxes or polygons, with a non-zero density so they have mass.
PhysxFixtureDef2D
| Property | Default | Meaning |
|---|---|---|
Shape | required | The PhysxShape2D |
Density | 0 | Mass per area. A dynamic body needs a fixture with a density above zero |
Friction | 0.2 | Friction coefficient |
Restitution | 0 | Bounciness: 0 stops, 1 bounces back at full speed |
Filter | PhysxFilter2D.Default | Collision filter |
IsTrigger | false | A sensor: reports contacts but does not collide |
UserData | null | Game data carried by the fixture |
CreateFixture returns an IPhysxFixture2D. Its Friction, Restitution, Filter, IsTrigger and UserData can be changed later; Density and Body are read-only. Fixtures also raise the collider events, on the stepping thread inside Step:
| Event | When | Impulse |
|---|---|---|
OnCollisionEnter | After the step in which two solid fixtures start touching | Total normal impulse the solver applied in that step (0 when they only came within the contact margin and did not push yet) |
OnCollisionStay | After every later step while they touch (not for contacts disabled in PreSolve) | Total normal impulse of that step, e.g. the weight of a resting body |
OnCollisionExit | When they stop touching | 0 |
OnTriggerEnter, OnTriggerExit | When one side is a trigger and they start or stop overlapping |
Collision Filters
public readonly record struct PhysxFilter2D
{
public PhysxFilter2D(ushort Category = 1, ushort Mask = 0xFFFF, short Group = 0);
public ushort Category { get; init; }
public ushort Mask { get; init; }
public short Group { get; init; }
public static readonly PhysxFilter2D Default; // category 1, mask 0xFFFF, group 0
}
Two fixtures collide when each one's Category is in the other's Mask. When they share a non-zero Group, the group decides instead: a positive group always collides, a negative group never does.
const ushort Cars = 1 << 1, Ball = 1 << 2, Walls = 1 << 3;
// Cars hit walls and the ball, but not each other.
var carFilter = new PhysxFilter2D(Category: Cars, Mask: Walls | Ball);
// Debris pieces with the same negative group pass through each other.
var debris = new PhysxFilter2D(Category: 1, Mask: 0xFFFF, Group: -1);
PhysxFilter2D.Default (category 1, mask 0xFFFF, group 0) collides with everything. A fixture def gets it when you do not set Filter, and default(PhysxFilter2D) and new PhysxFilter2D() are equal to it.
Contact Listener
A world has one contact listener. Its callbacks run on the stepping thread, inside Step. Every method has an empty default, so implement only the ones you need.
public interface IPhysxContactListener2D
{
void BeginContact(IPhysxContact2D contact) { }
void EndContact(IPhysxContact2D contact) { }
void PreSolve(IPhysxContact2D contact) { }
void PostSolve(IPhysxContact2D contact, float normalImpulse) { }
}
world.SetContactListener(listener); // null removes it
| Callback | When | Typical use |
|---|---|---|
BeginContact | Two fixtures start touching (or a trigger starts overlapping) | Goal triggers, pickups, hit sounds |
EndContact | They stop touching | Leaving a zone |
PreSolve | After collision detection, before the solver | One-way platforms, per-contact bounce or friction |
PostSolve | After the solver, with the largest normal impulse of the contact | Damage from hit strength, impact effects |
IPhysxContact2D exposes:
| Member | Meaning |
|---|---|
FixtureA, FixtureB, BodyA, BodyB | The two sides, with their UserData |
IsTouching | The shapes overlap (or are within the solver margin) |
IsEnabled | Set to false in PreSolve to skip this contact for this step |
Restitution, Friction | Set in PreSolve to override the mixed values for this contact |
PointCount | Points in the manifold, 0 when the shapes do not touch |
GetWorldManifold() | The world-space normal (from A to B), up to two points and their Midpoint |
public sealed class ArenaContacts : IPhysxContactListener2D
{
public void BeginContact(IPhysxContact2D c)
{
if (Is(c, "ball", "goal")) OnGoal();
}
public void PreSolve(IPhysxContact2D c)
{
// One-way platform: a body below passes through.
if (c.FixtureA.UserData is "platform" && c.GetWorldManifold().Normal.Y < 0)
c.IsEnabled = false;
// The ball always bounces hard off cars, whatever the materials say.
if (Is(c, "ball", "car"))
c.Restitution = 0.9f;
}
public void PostSolve(IPhysxContact2D c, float normalImpulse)
{
if (normalImpulse > 5f) OnHardHit(c.GetWorldManifold().Midpoint, normalImpulse);
}
private static bool Is(IPhysxContact2D c, string a, string b) =>
(c.FixtureA.UserData ?? c.BodyA.UserData, c.FixtureB.UserData ?? c.BodyB.UserData) switch
{
var (x, y) when Equals(x, a) && Equals(y, b) => true,
var (x, y) when Equals(x, b) && Equals(y, a) => true,
_ => false,
};
private void OnGoal() { }
private void OnHardHit(Vector2 at, float impulse) { }
}
The contact passed to a callback is one view the world reuses for every callback, so contact callbacks do not allocate. It is valid only inside the callback that receives it; using it later throws InvalidOperationException. To keep a contact, copy it with ToInfo(): a PhysxContactInfo2D with the fixtures, bodies, IsTouching, IsEnabled, Restitution, Friction and the world Manifold at that moment.
private readonly List<PhysxContactInfo2D> _hits = new();
public void BeginContact(IPhysxContact2D c) => _hits.Add(c.ToInfo());
Note:
Do not create or remove bodies inside a callback: the world is in the middle of its step. Record the event and act after Step returns.
Enumerating Contacts
world.Contacts lists the world's current contacts. It includes pairs whose bounding boxes only overlap, so check IsTouching:
var ballOnGround = false;
foreach (var c in world.Contacts)
{
if (!c.IsTouching || !c.IsEnabled) continue;
if (Equals(c.BodyA.UserData, "ball") || Equals(c.BodyB.UserData, "ball"))
ballOnGround = true;
}
Each item is a separate view of its contact, so collecting them (world.Contacts.ToList()) is safe. A view stays valid until the world's contact list may change: the next Step, removing a body, disabling a body or destroying a fixture. After that, using it throws InvalidOperationException; copy it with ToInfo() to keep it longer.
world.ContactsOf(body) lists one body's contacts, in the world order restricted to that body (which is also the order of the engine's per-body contact list). To dispatch contacts by the types of their tags instead of testing both orders by hand, see ContactRouter2D and the typed contact queries (TouchingContacts<TA, TB>(), TouchingContactsOf<TOther>(body)).
Ray Casts
RayCast(from, to, callback) calls the callback once for every fixture the ray crosses, in no particular order. The callback's return value controls the rest of the cast:
| Return | Effect |
|---|---|
-1 | Ignore this fixture and continue as if it were not there |
0 | Stop the cast |
fraction | Clip the ray to this hit: later calls can only report closer hits. Returning fraction every time leaves you with the closest hit |
1 | Continue without clipping: you see every hit |
public sealed class ClosestSolidHit : IPhysxRayCastCallback2D
{
public bool Found;
public Vector2 Point;
public Vector2 Normal;
public IPhysxFixture2D? Fixture;
public float OnHit(IPhysxFixture2D fixture, Vector2 point, Vector2 normal, float fraction)
{
if (fixture.IsTrigger) return -1; // look through triggers
Found = true;
Point = point;
Normal = normal;
Fixture = fixture;
return fraction; // keep the closest
}
}
var hit = new ClosestSolidHit();
world.RayCast(eye, eye + direction * 50f, hit);
A callback may start another ray cast; the nested cast gets its own state.
There is also RayCast(PhysxRay2D ray, int maxHits = 1), which returns the closest maxHits hits as PhysxRaycastHit2D (body, point, normal, fraction), sorted by fraction (closest first). Use the callback form when you need to filter hits (triggers, the caster itself) or stop early.
var hits = world.RayCast(new PhysxRay2D(eye, eye + direction * 50f), maxHits: 3);
Complete Example
A ball bouncing in a box, with a contact listener that counts wall hits and records the hardest impact, and a ray cast that measures the distance to the floor.
using System.Numerics;
using Altruist.Physx;
using Altruist.Physx.Contracts;
using Altruist.Physx.TwoD;
public sealed class BallInABox : IPhysxContactListener2D, IDisposable
{
private readonly IPhysxWorldEngine2D _world;
public IPhysxBody2D Ball { get; }
public int WallHits { get; private set; }
public float HardestImpact { get; private set; }
public BallInABox()
{
_world = PhysxWorldEngine2D.Create(new PhysxWorldSettings2D
{
Gravity = new Vector2(0, -10),
FixedDeltaTime = 1f / 60f,
});
_world.SetContactListener(this);
// A 20 x 10 box. Wound clockwise, so the loop collides on its inside.
var walls = _world.CreateBody(new PhysxBodyDef2D { Type = PhysxBodyType.Static, UserData = "walls" });
_world.CreateFixture(walls, new PhysxFixtureDef2D
{
Shape = PhysxShape2D.Chain(new[]
{
new Vector2(-10, 0), new Vector2(-10, 10), new Vector2(10, 10), new Vector2(10, 0),
}, loop: true),
Friction = 0.1f,
});
Ball = _world.CreateBody(new PhysxBodyDef2D
{
Type = PhysxBodyType.Dynamic,
Position = new Vector2(0, 5),
Bullet = true,
UserData = "ball",
});
_world.CreateFixture(Ball, new PhysxFixtureDef2D
{
Shape = PhysxShape2D.Circle(0.5f),
Density = 1f,
Restitution = 0.8f,
});
Ball.LinearVelocity = new Vector2(12, 4);
}
public void Step() => _world.Step(_world.FixedDeltaTime);
public float? DistanceToFloor()
{
var hit = new ClosestHit();
_world.RayCast(Ball.Position, Ball.Position + new Vector2(0, -20), hit);
return hit.Found ? Ball.Position.Y - hit.Point.Y : null;
}
// Contact listener: runs inside Step, on the stepping thread.
public void BeginContact(IPhysxContact2D c)
{
if (Equals(c.BodyA.UserData, "walls") || Equals(c.BodyB.UserData, "walls"))
WallHits++;
}
public void PostSolve(IPhysxContact2D c, float normalImpulse) =>
HardestImpact = MathF.Max(HardestImpact, normalImpulse);
public void Dispose() => _world.Dispose();
private sealed class ClosestHit : IPhysxRayCastCallback2D
{
public bool Found;
public Vector2 Point;
public float OnHit(IPhysxFixture2D fixture, Vector2 point, Vector2 normal, float fraction)
{
if (Equals(fixture.Body.UserData, "ball")) return -1; // ignore the ball itself
Found = true;
Point = point;
return fraction; // clip: keep the closest
}
}
}
using var sim = new BallInABox();
for (var tick = 0; tick < 600; tick++)
sim.Step();
Console.WriteLine($"ball at {sim.Ball.Position}, {sim.WallHits} wall hits, " +
$"hardest impact {sim.HardestImpact:F2}, {sim.DistanceToFloor():F2} above the floor");
Determinism
A world is deterministic: two worlds built the same way and stepped with the same fixed delta and the same inputs end up bit-identical. That is what lets an authoritative server, replays and golden-file tests agree on a match.
To keep it that way:
- Step with a constant
deltaTime(normallyworld.FixedDeltaTime), never the measured frame time. - Create bodies and fixtures in the same order, and apply inputs (velocities, forces,
SetTransform) at the same step. - Keep all game decisions that feed back into the world inside the step loop, not on timers or other threads.
- Do not let a world's results depend on another world: each world has its own contacts and listener.
The guarantee covers the same build on the same platform. The simulation uses float math, so do not rely on bit-identical results across different CPU architectures or runtimes.
Threading
- One world, one thread at a time. A world is not thread-safe. Its
Step, body changes and queries must not run concurrently with each other. - Independent worlds may step in parallel. Different worlds can step on different threads at the same time, for example one world per match room stepped by the engine's
altruist:game:engine:step-workers. - Worlds may move between threads. A world stepped on one thread can be stepped on another thread next time.
Box2DSharp keeps a static pool of contact objects per shape pair, which is not safe when two worlds step at once. Box2DThreading replaces those pools with per-thread pools, so each thread reuses its own contacts. It is installed automatically when the first world is created, and installing it again does nothing. On a single thread the pooling behaves as before, and worlds stepped in parallel produce results bit-identical to the same worlds stepped one after another.
if (!PhysxWorldEngine2D.ParallelWorldsSupported)
{
// Box2DThreading.FailureReason says why (an incompatible Box2DSharp version).
// Step the worlds one after another instead.
}
Parallel.ForEach(rooms, room => room.World.Step(room.World.FixedDeltaTime));
PhysxWorldEngine2D.ParallelWorldsSupported installs the per-thread pools if needed and reports whether that succeeded. When it is false, Box2DThreading.FailureReason explains why, and you must step worlds serially.
API Reference
| Type | Purpose |
|---|---|
PhysxWorldEngine2D | Create(settings), ParallelWorldsSupported |
IPhysxWorldEngineFactory2D | Injectable factory (every environment mode): Create(settings), Create(gravity, fixedDeltaTime) |
IPhysxWorldEngine2D | Step, CreateBody, CreateFixture, RemoveBody, SetContactListener, RayCast, Contacts, Bodies, FixedDeltaTime, Dispose |
PhysxWorldSettings2D | Gravity, fixed delta time, solver iterations, fixed stepping (MaxSubSteps) |
PhysxBodyDef2D | Everything a body is created with |
IPhysxBody2D | A body: transform, velocities, mass, forces, user data, frame conversions |
PhysxShape2D | Circle, Box, Polygon, Chain, Edge |
PhysxFixtureDef2D | Shape, material, filter, trigger, user data |
IPhysxFixture2D | A fixture: material, filter, trigger, body, user data, collider events |
PhysxFilter2D | Category, mask, group; PhysxFilter2D.Default |
IPhysxContactListener2D | BeginContact, EndContact, PreSolve, PostSolve |
IPhysxContact2D | A contact view: fixtures, bodies, overrides, manifold, ToInfo() |
PhysxContactInfo2D | A copy of a contact, safe to keep |
PhysxWorldManifold2D | Normal, points, Midpoint |
IPhysxRayCastCallback2D | Per-hit ray cast callback |
Rotation2D | Rotate (local to world) and Unrotate (world to local) |
Box2DThreading | Per-thread contact pools: EnsureInstalled(), ParallelWorldsSupported, FailureReason |