2D Motion Physics

The physics layer turns the math helpers into operations on velocities and bodies: cancel motion into a surface, bounce, drag, per-body gravity, steer the spin, aggregate contact normals, cast a ray for the closest hit, predict a flight, snapshot a body. It works on any IPhysxBody2D, so it serves the 2D simulation API as well as hand-written bodies. The gameplay verbs are built on it.

using Altruist.Physx;        // Kinematics
using Altruist.Physx.TwoD;   // Velocity2D, Ballistics2D, ContactNormals2D, body extensions, ray casts

Conventions: side view with +Y up; gravity is the magnitude (≥ 0) of the pull toward -Y. Body rotations are counter-clockwise (Box2D); a body's local +Y is its "up". Every operation is deterministic and evaluates exactly the formula in its summary, so it replaces the same code written inline without changing a bit.

Velocities (Velocity2D)

Pure functions on Vector2:

MethodFormula / use
ApproachSpeed(v, n)-Dot(v, n): how hard v hits a surface whose normal points at the mover
CancelInto(v, n, amount = 1)Remove amount of the motion going into n (unchanged when moving away). Before a jump, or a thrust that cancels opposing motion
Bounce(v, n, normalSpeed, tangentKeep)n * normalSpeed + (v - n * Dot(v, n)) * tangentKeep: rebounds and launch pads
PointVelocity(linear, angular, offset)Velocity of a rigid-body point: linear + ω × offset
ClosingSpeed(a, b, along)Dot(a - b, along): how fast a gains on b in a direction (a rammer on its victim, along its nose); the difference first, as written
CentripetalAcceleration(v, radius)(v.X * v.X + v.Y * v.Y) / radius: e.g. extra grip into a surface at speed
AccelerateAlong(v, dir, accel, dt)v + dir * accel * dt (negative accel pushes against dir, e.g. grip into a surface)
ApplyLinearDrag(v, drag, dt)v * (1 - drag * dt)
ApplyGravity(v, gravity, dt, scale = 1)(v.X, v.Y - gravity * dt * scale): per-body gravity when the world's gravity is zero
KeepMinimumSpeedX(v, min, fallbackDirection)A body that must never come to rest horizontally
var rebound = Velocity2D.Bounce(impactVelocity, normal,
    normalSpeed: Velocity2D.ApproachSpeed(impactVelocity, normal) * restitution,
    tangentKeep: 0.8f);

Kinematics

Dimension-agnostic (Altruist.Physx.Kinematics):

MethodMeaning
StoppingDistance(speed, deceleration)speed * speed / (2 * deceleration)
TimeToCover(distance, initialSpeed, acceleration, maxSpeed)Accelerate up to maxSpeed, then cruise. A start above maxSpeed cruises at maxSpeed
CentripetalAcceleration(speed, radius)speed * speed / radius

Ballistics

Ballistics2D answers "where will it be" in closed form and with a cheap fixed-step predictor:

MethodMeaning
PositionAt(p, v, gravity, t) / VelocityAt(v, gravity, t)Closed-form free flight
ApexHeight(vy, gravity)How much higher it still rises (0 when not going up)
LaunchSpeedY(rise, gravity, t) / LaunchVelocity(delta, gravity, t)The throw that covers delta in exactly t seconds
Step(ref p, ref v, gravity, dt)One semi-implicit Euler step
BounceOnFloor(ref p, ref v, floorY, restitution)Reflect off a flat floor
Predict(p, v, gravity, dt, steps[, floorY, restitution])State after steps steps (optionally bouncing)
FirstStepWhere(p, v, gravity, dt, steps, predicate)First step (1-based) whose position satisfies predicate, or -1
FirstZoneEntered(p, v, gravity, dt, steps, zones, floorY = -∞)(Zone, Step): the first of several Aabb2D zones (checked in order) a step lands in, and the step (1-based); (-1, -1) when none, or when the flight drops below floorY first. Margins: pass zone.Grow(mx, my)
var throwVelocity = Ballistics2D.LaunchVelocity(target - hand, gravity, t: 0.8f);
var willScore = Ballistics2D.FirstStepWhere(ball.Position, ball.LinearVelocity, gravity, 0.05f, 24,
    p => goalZone.Contains(p)) >= 0;
// Which goal does the ball reach first (if any) before touching the floor?
Span<Aabb2D> goals = [theirGoal.Grow(1, -0.3f), ourGoal.Grow(1.5f, 0.5f)];
var (zone, step) = Ballistics2D.FirstZoneEntered(ball.Position, ball.LinearVelocity, gravity, 1f / 30, 60, goals, floorY: radius);

Note:

The stepped predictor is semi-implicit Euler, not the engine's integrator. When a prediction must match the simulation exactly (contacts, restitution, solver), step a scratch world and restore it with BodyState2D.

Contact Normals (ContactNormals2D)

Sum a body's contact normals over a step and tell ground from walls:

var contacts = new ContactNormals2D();
foreach (var c in world.Contacts)
{
    if (!c.IsTouching || !IsMine(c)) continue;
    var n = c.BodyA == body ? -c.GetWorldManifold().Normal : c.GetWorldManifold().Normal; // out of the surface
    contacts.Add(n, up: body.GetWorldVector(Vector2.UnitY), minUpDot: 0.7f);
}
if (contacts.HasSupport) groundNormal = contacts.SupportDirection;   // averaged, unit (zero when none)
if (contacts.HasAny) anySurfaceNormal = contacts.AllDirection;

Contact Impacts (ContactImpact2D)

One body (the striker: a bat, a kicker, a vehicle) hitting another (the target: a ball, a puck). Capture it in pre-solve, before the solver changes the velocities, and build a designed response from it. Normal points from the striker into the target.

// Pre-solve:
var impact = ContactImpact2D.Capture(point, normal, striker: car, target: ball);

// After the step: a designed hit.
if (impact.ClosingSpeed < minSpeed || impact.StrikerInto < minSpeed) return;   // soft touch or the ball landing on it
var outN = impact.ClosingSpeed * (1 + restitution) * zoneMultiplier + impact.TargetSpeed * speedCarry;
var outT = impact.TargetTangential * (1 - carry) + impact.StrikerTangential * carry;
ball.SetVelocityInFrame(impact.Frame, outN, outT);
ball.AddSpinFromSlip(impact.Slip, ballRadius, spinFactor);
MemberFormula
Point, Normal, StrikerVelocity, StrikerSpin, StrikerCenter, TargetVelocityAs captured (Capture reads LinearVelocity, AngularVelocityZ, WorldCenter)
StrikerPointVelocityVelocity2D.PointVelocity(StrikerVelocity, StrikerSpin, Point - StrikerCenter): spin included
ClosingSpeedDot(StrikerPointVelocity - TargetVelocity, Normal)
StrikerIntoDot(StrikerPointVelocity, Normal): the striker itself moving into the target
TargetSpeedTargetVelocity.Length()
FrameNormalFrame2D.Left(Normal) (tangent (-n.Y, n.X))
TargetTangential / StrikerTangential / SlipFrame.Across(...) of each; StrikerTangential - TargetTangential

Body Operations (BodyMotionExtensions2D)

Extension methods on IPhysxBody2D. Each reads the body, applies one formula above, and writes once.

GroupMethods
ReadSpeedAlong(dir), VelocityAtPoint(worldPoint), BoxContains(worldPoint, halfW, halfH, padX, padY), BoxDistanceSquared(worldPoint, halfW, halfH)
LinearSetVelocityInFrame(frame, alongNormal, alongTangent) (frame.Compose(...)), AddVelocityAlong, SetVelocityAlong, ApproachVelocityAlong(dir, target, accel, dt), RedirectVelocityAlong, AccelerateAlong, CancelVelocityInto, BounceVelocity, ReduceSpeed, ClampSpeed, ApplyLinearDrag, ApplyGravity, KeepMinimumSpeedX, BlendMotionFrom(linearBefore, angularBefore, keep)
AngularAddSpinFromSlip(slip, radius, factor) (w + slip / radius * factor), AlignUpToNormal(normal, rate), ArriveAtAngle(target, time, minTime), RotateTowardAngle(target, gain), ApproachAngularVelocity(rate, accel, dt), SteerAngularVelocity(target, gain, maxRate, accel, dt)
StateCopyMotionFrom(source), CaptureState()

Body Snapshots (BodyState2D)

var saved = body.CaptureState();            // position, angle, velocities, awake, enabled
// ... step a what-if rollout ...
saved.Restore(body);                        // SetTransform, velocities, wake
saved.Restore(body, wake: false, restoreEnabled: true);

Restore writes IsEnabled first (when asked), so an engine re-creates the body's broad-phase entries at the restored place.

Ray Casts (ClosestRayHit2D)

The world's RayCast(from, to, callback) reports hits in no particular order. ClosestRayHit2D keeps the closest hit a filter accepts, and can collect over several rays:

var below = new ClosestRayHit2D(length: reach, filter: f => f.UserData is SurfaceTag);
world.RayCast(leftFoot, leftFoot - up * reach, below);
world.RayCast(rightFoot, rightFoot - up * reach, below);   // keeps the nearer of the two
if (below.Found) Land(below.Point, below.Normal, below.Distance);

var hit = world.RayCastClosest(eye, target, f => f.UserData is Wall);   // one ray, new callback

Steering (BodySteeringExtensions2D)

Top-down, yaw-convention verbs that move a body directly (kinematic style): GetRotation, DistanceTo, IsFacing(target, halfAngleDegrees), FaceToward(point), TurnToward(point, maxRadPerSec, dt), MoveToward(point, speed, dt), MoveTowardAngle(yaw, speed, dt), Stop, LaunchImpulse(dir, magnitude) / LaunchImpulse(impulse) and LaunchVelocity(velocity) (through ApplyForce).

npc.TurnToward(player.Position, maxAngularSpeedRadPerSec: MathF.PI, dt);
if (npc.IsFacing(player.Position, halfAngleDegrees: 20)) npc.MoveToward(player.Position, speed: 4f, dt);