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

PropertyDefaultMeaning
Gravity(0, 0)World gravity in units per second squared
FixedDeltaTime1/60The fixed step, exposed as world.FixedDeltaTime. Used for stepping only with MaxSubSteps above zero
VelocityIterations8Velocity solver iterations per step
PositionIterations3Position solver iterations per step
MaxSubSteps0Fixed 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

PropertyDefaultMeaning
TypeStaticStatic, Dynamic or Kinematic. Static is the default, as in Box2D: set Dynamic for a body that moves
Position(0, 0)World position
Angle0Rotation in radians
BulletfalseContinuous collision against other dynamic bodies, for fast movers such as a ball
AllowSleeptrueThe body may sleep when it comes to rest
FixedRotationfalseThe body never rotates
LinearDamping0Linear velocity damping
AngularDamping0Angular velocity damping
GravityScale1Multiplier of the world gravity for this body
UserDatanullGame data carried by the body
IdnullBody 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:

MemberMeaning
Position, RotationZWorld position and angle in radians. Setting either teleports the body
LinearVelocity, AngularVelocityZVelocities
MassMass 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
UserDataGame data (a tag, the owning entity)
IsAwakeA sleeping body is skipped by the solver until something wakes it. Set it to true to wake the body
IsEnabledA disabled body takes no part in the simulation: no contacts, no motion
WorldCenterCenter 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

FactoryShape
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

PropertyDefaultMeaning
ShaperequiredThe PhysxShape2D
Density0Mass per area. A dynamic body needs a fixture with a density above zero
Friction0.2Friction coefficient
Restitution0Bounciness: 0 stops, 1 bounces back at full speed
FilterPhysxFilter2D.DefaultCollision filter
IsTriggerfalseA sensor: reports contacts but does not collide
UserDatanullGame 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:

EventWhenImpulse
OnCollisionEnterAfter the step in which two solid fixtures start touchingTotal normal impulse the solver applied in that step (0 when they only came within the contact margin and did not push yet)
OnCollisionStayAfter 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
OnCollisionExitWhen they stop touching0
OnTriggerEnter, OnTriggerExitWhen 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
CallbackWhenTypical use
BeginContactTwo fixtures start touching (or a trigger starts overlapping)Goal triggers, pickups, hit sounds
EndContactThey stop touchingLeaving a zone
PreSolveAfter collision detection, before the solverOne-way platforms, per-contact bounce or friction
PostSolveAfter the solver, with the largest normal impulse of the contactDamage from hit strength, impact effects

IPhysxContact2D exposes:

MemberMeaning
FixtureA, FixtureB, BodyA, BodyBThe two sides, with their UserData
IsTouchingThe shapes overlap (or are within the solver margin)
IsEnabledSet to false in PreSolve to skip this contact for this step
Restitution, FrictionSet in PreSolve to override the mixed values for this contact
PointCountPoints 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:

ReturnEffect
-1Ignore this fixture and continue as if it were not there
0Stop the cast
fractionClip the ray to this hit: later calls can only report closer hits. Returning fraction every time leaves you with the closest hit
1Continue 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 (normally world.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

TypePurpose
PhysxWorldEngine2DCreate(settings), ParallelWorldsSupported
IPhysxWorldEngineFactory2DInjectable factory (every environment mode): Create(settings), Create(gravity, fixedDeltaTime)
IPhysxWorldEngine2DStep, CreateBody, CreateFixture, RemoveBody, SetContactListener, RayCast, Contacts, Bodies, FixedDeltaTime, Dispose
PhysxWorldSettings2DGravity, fixed delta time, solver iterations, fixed stepping (MaxSubSteps)
PhysxBodyDef2DEverything a body is created with
IPhysxBody2DA body: transform, velocities, mass, forces, user data, frame conversions
PhysxShape2DCircle, Box, Polygon, Chain, Edge
PhysxFixtureDef2DShape, material, filter, trigger, user data
IPhysxFixture2DA fixture: material, filter, trigger, body, user data, collider events
PhysxFilter2DCategory, mask, group; PhysxFilter2D.Default
IPhysxContactListener2DBeginContact, EndContact, PreSolve, PostSolve
IPhysxContact2DA contact view: fixtures, bodies, overrides, manifold, ToInfo()
PhysxContactInfo2DA copy of a contact, safe to keep
PhysxWorldManifold2DNormal, points, Midpoint
IPhysxRayCastCallback2DPer-hit ray cast callback
Rotation2DRotate (local to world) and Unrotate (world to local)
Box2DThreadingPer-thread contact pools: EnsureInstalled(), ParallelWorldsSupported, FailureReason