Math Helpers (Numerics)

Altruist ships the math a game simulation keeps rewriting: clamps and approaches, angle wrapping, safe normalization, components along a surface, tuning curves, a replayable random generator, box and ray tests, arc tessellation. This page covers the math layer; the physics layer and the gameplay verbs are built on it, and the TypeScript package mirrors all three for game clients.

Three Layers

Each layer is built only on the one below it. Game code should read like its rules, so it usually calls the top layer.

LayerNamespace (project)What it holds
MathAltruist.Numerics, Altruist.TwoD.Numerics (Core)Scalars, angles, vectors, RNG, curves, geometry. No bodies, no game concepts
PhysicsAltruist.Physx, Altruist.Physx.TwoD (Physx)Velocity and angular operations, gravity, drag, contacts, ray casts, ballistics, body snapshots
GameplayAltruist.Gaming.TwoD (Gaming)Designer-level verbs on a body: JumpOff, BounceOff, AlignToSurface, AimAt, PredictPosition, CanReachHeight...
// Gameplay layer: reads like the rule.
car.JumpOff(groundNormal, jumpSpeed);

// ...which is the physics layer:
car.LinearVelocity = VectorMath2D.AddAlong(Velocity2D.CancelInto(car.LinearVelocity, groundNormal), groundNormal, jumpSpeed);

// ...which is the math layer:
var into = v.X * n.X + v.Y * n.Y;
if (into < 0) v = new Vector2(v.X - n.X * into, v.Y - n.Y * into);
v = new Vector2(v.X + n.X * jumpSpeed, v.Y + n.Y * jumpSpeed);

Determinism

Every helper on these pages is deterministic: the same inputs give the same bits on the same runtime. More than that, each one evaluates exactly the expression written in its XML summary (same operands, same grouping, float / MathF, no fused multiply-add). Replacing that expression written inline with the helper therefore changes no bit, which matters when golden tests hash a simulation's state every tick.

Note:

Bit-identical means "same as the same formula written by hand", not "same as another formula for the same thing". Angle.Wrap and Angle.Normalize both wrap an angle and can differ in the last bit; VectorMath2D.Lerp is not Vector2.Lerp. Pick the helper whose formula matches the code you replace.

Scalars (Scalar)

using Altruist.Numerics;

MethodFormula
Clamp(v, min, max)v < min ? min : v > max ? max : v (never throws, NaN stays NaN)
Clamp01(v)Clamp(v, 0, 1)
ClampMinMax(v, min, max)MathF.Min(MathF.Max(v, min), max): for code written that way (it differs from Clamp when min > max and turns -0 into +0 at a bound of 0)
Approach(current, target, maxDelta)Step toward the target without overshooting: current < target ? MathF.Min(current + maxDelta, target) : MathF.Max(current - maxDelta, target)
Lerp(a, b, t) / InverseLerp(a, b, v) / Remap(v, inMin, inMax, outMin, outMax)a + (b - a) * t, (v - a) / (b - a), unclamped
Pow01(t, exponent)MathF.Pow(Clamp01(t), exponent): a power ease on [0, 1]
Sign(v)1, -1 or 0 as a float; NaN gives 0 (MathF.Sign throws)
SignOr(v, fallback)The sign as an int, or fallback for zero
RoundHalfUp(v)MathF.Floor(v + 0.5f) (JavaScript-style rounding of halves)
Quantize(v, steps = 127)MathF.Round(Clamp(v, -1, 1) * steps) / steps: an analog axis in signed-byte steps
speed = Scalar.Approach(speed, maxSpeed, acceleration * dt);
var facing = Scalar.SignOr(velocity.X, fallback: lastFacing);
var stick = Scalar.Quantize(rawStickX);            // same value on client, server and wire
var difficulty = Scalar.Lerp(easy, hard, Scalar.Pow01(skill, 1.5f));

Angles (Angle)

Radians, dimension-agnostic.

MethodWhat it does
Normalize(a)Wrap into [-π, π] with an IEEE remainder (+π stays +π)
ShortestDifference(from, to)Normalize(to - from): the signed short way around
MoveToward(current, target, maxDelta)Step an angle the short way, never overshooting
ToRadians(deg) / ToDegrees(rad)deg * (π / 180) / rad * (180 / π)
Wrap(a)Modulo wrap (a + π) % 2π - π (negative remainders shifted up): result in [-π, π), +π becomes -π
WrappedDelta(from, to)Wrap(to - from)
NearestEquivalent(reference, angle)reference + Wrap(angle - reference): the same direction, the fewest turns away from reference
NearestFullTurn(a)a - Wrap(a): the "level" multiple of 2π nearest to a (after a spin)
RateToward(current, target, gain)Wrap(target - current) * gain: a proportional turn rate
ClampedRateToward(current, target, gain, maxRate)The same, clamped to ±maxRate
ArriveRate(current, target, time, minTime)Wrap(target - current) / MathF.Max(time, minTime): the rate that arrives in time
FullTurnRate(time, direction)direction * MathF.PI * 2 / time: the spin rate of a timed full turn (a flip)
SectorToward(dx, dy, sectors) / Sector(radians, sectors)double: which of sectors equal slices a direction falls in, counter-clockwise from -X: (int)Math.Floor((Math.Atan2(dy, dx) + Math.PI) / (2 * Math.PI) * sectors) % sectors (+π wraps to 0). Sector takes any angle through its sine and cosine. For cells, coverage maps, discrete aim

Note:

Normalize and Wrap are both kept on purpose. Use Wrap when the code you replace computes (a + PI) % (2 * PI); its result matches that code bit for bit. Use Normalize for new code that does not care.

Curves (PiecewiseLinear)

Tuning tables as (X, Y) points sorted by X (any ReadOnlySpan<(float, float)>, so an array of named tuples works).

(float Level, float Skill)[] byLevel = { (1, 0.05f), (10, 0.3f), (30, 0.7f), (50, 1f) };
var skill = PiecewiseLinear.Evaluate(byLevel, playerLevel);   // clamped at both ends

(float Time, float Height)[] jumpRise = { (0, 0), (0.17f, 2.6f), (0.33f, 4.5f), (0.5f, 5.4f) };
var t = PiecewiseLinear.Inverse(jumpRise, 3f);                 // when the curve reaches 3; +∞ if never

Deterministic Random (DeterministicRandom)

A seedable xorshift32 whose sequence is fixed forever, identical on every runtime and in the TypeScript package. Use it for anything that must replay exactly (authoritative simulations, bots, golden tests); System.Random may change its algorithm between .NET versions.

var rng = new DeterministicRandom(seed: matchId);
double u   = rng.Next();        // [0, 1)
float  s   = rng.Signed();      // [-1, 1)
float  g   = rng.Gaussian();    // approx. normal, mean 0, std-dev 1 (sum of three uniforms)
int    j   = rng.Jitter(3);     // integer in [-3, 3]
bool   hit = rng.Chance(0.25);  // 25 %

uint saved = rng.State;                         // rollback / replays
var again = DeterministicRandom.FromState(saved);

2D Vectors (VectorMath2D)

using Altruist.TwoD.Numerics; — functions on System.Numerics.Vector2 that the BCL does not offer.

MethodFormula / meaning
Length(v), Length(x, y)MathF.Sqrt(x * x + y * y)
NormalizeOrZero(v) (+ out length)Divide by the length, or by 1 for a zero vector (no epsilon)
NormalizeOr(v, fallback)The unit vector, or fallback for a zero vector
Cross(a, b) / Cross(w, r)a.X * b.Y - a.Y * b.X / ω × r = (-w * r.Y, w * r.X)
Project(v, axis) / Reject(v, normal)The part along a unit axis / across it
AddAlong(v, dir, amount)v + dir * amount, component by component
WithComponentAlong(v, dir, value)Set the component along dir, keep the rest
ApproachComponentAlong(v, dir, target, maxDelta)Move the component along dir toward target (driving along a surface tangent)
RedirectAlong(v, axis)All of the length onto the axis, on the side v points to
ClampLength(v, max) / ShortenBy(v, length, amount)Speed caps and speed loss
Lerp(a, b, t)a + (b - a) * t
RotateDegrees(v, degrees)v turned counter-clockwise: c = Cos(degrees * π / 180), s = Sin(...), (v.X * c - v.Y * s, v.X * s + v.Y * c) (radians as (degrees * π) / 180, not ToRadians)

Directions, Yaw and Rotation

Two angle conventions coexist in 2D, and the helpers name them:

  • Yaw (Yaw2D, Direction2D.TowardAngle, SpatialQueries2D, BodySteeringExtensions2D): Atan2(dx, dy), 0 faces +Y, positive turns toward +X. Top-down games use it.
  • Rotation (Rotation2D, physics bodies, Direction2D.FromPolar): counter-clockwise radians, 0 = +X axis; a body's local +Y is its "up". Side-view games and Box2D use it.
TypeMembers
Direction2DBetween(from, to) / Toward (unit vector, zero when closer than 1e-6), WithDistance, TowardAngle(yaw), Perpendicular (90° CCW), PerpendicularClockwise (90° CW: the +X tangent of a floor normal), FromPolar(radians), FromPolarDegrees(degrees), ClampElevation(unit, minY, maxY)
Yaw2DFromDirection(dx, dy), FromVector(dir), ToDirection(yaw), Calculate(rotation)
Distance2DBetween(a, b), Squared(a, b), Weighted(dx, dy, weightY) / WeightedBetween(a, b, weightY) = |dx| + |dy| * weightY ("who is closer, height counting half")
Rotation2DFromRadians / FromDegrees, Rotate(v) (local to world), Unrotate(v), AngleAligningUp(normal) = Atan2(-n.X, n.Y): the rotation whose up is normal; UpAt(radians) = (-sin, cos); AngleAligningForward(direction, facing) = Atan2(d.Y, d.X) - (facing > 0 ? 0 : π): the rotation that points a left/right-facing body's front along direction
var aim = Direction2D.FromPolarDegrees(35);                   // (cos 35°, sin 35°)
var shot = Direction2D.ClampElevation(aim, minY: -0.3f, maxY: 0.92f);
var tangent = Direction2D.PerpendicularClockwise(groundNormal);

Spatial Queries (SpatialQueries2D)

Stateless yaw-convention predicates for abilities and targeting: IsInRange(from, to, range), IsInCone(origin, yaw, halfAngle, range, target), IsInLine(origin, yaw, length, halfWidth, target). Each has a Position2D overload.

Geometry (Geometry2D, Aabb2D)

MemberMeaning
Geometry2D.BoxContainsLocal(local, halfW, halfH)Point in a centered box (pass a point already in the box's frame, e.g. body.GetLocalPoint(p); enlarge the half extents for padding)
Geometry2D.BoxDistanceSquaredLocal(local, halfW, halfH)Squared distance to the box, 0 inside
Geometry2D.RotatedBoxHalfExtentY(halfW, halfH, angle) / ...XHow far a box rotated by angle reaches along the frame's Y (X) axis
Geometry2D.PointToRay(origin, dir, point)(Along, Offset): distance along the ray and from its line
Geometry2D.ClassifyBoxSide(local, halfW, halfH, axisBias)Which side of a centered box a local point or direction is on (BoxSide2D.Front = +X, Back, Top = +Y, Bottom): the dominant axis of the point scaled to a unit square; axisBias > 1 favors front / back on the corners. For a body facing either way pass (local.X * facing, local.Y)
Aabb2D(MinX, MaxX, MinY, MaxY)Contains(x, y), Contains(x, y, marginX, marginY), Contains(Vector2), Grow(marginX, marginY) (negative shrinks; same bounds as the margins written inline), Center, Width, Height, Intersects, FromCorners

Contact and Surface Frames (NormalFrame2D)

A unit normal and its tangent, so contact and surface code says "speed into the wall", "speed along the floor" instead of dot products. Pick the tangent side: NormalFrame2D.Left(n) uses (-n.Y, n.X) (counter-clockwise of the normal), NormalFrame2D.Right(n) uses (n.Y, -n.X) (clockwise: +X on a floor).

MemberFormula
Normal, TangentThe unit normal and the tangent on the chosen side
Along(v) / Across(v) / Into(v)Dot(v, Normal) / Dot(v, Tangent) / -Dot(v, Normal)
Compose(alongNormal, alongTangent)Normal * alongNormal + Tangent * alongTangent
TangentToward(direction, fallbackSign)The tangent turned to the side direction points to, or to fallbackSign when square to the surface
HeightOf(point, surfacePoint)(p.X - s.X) * n.X + (p.Y - s.Y) * n.Y: how far a point is above the surface
var floor = NormalFrame2D.Right(groundNormal);
var speedAlong = floor.Across(body.LinearVelocity);             // + = toward +X on a floor
var forward = floor.TangentToward(nose, fallbackSign: facing);  // along the surface, the way the nose points
var drop = MathF.Max(0, floor.HeightOf(body.Position, hitPoint) - halfHeight);
if (floor.Into(velocity) > 0) Land();

Note:

Along, Across and Into use Vector2.Dot, as contact code written with Vector2 does. Vector2.Dot gives the same bits as v.X * n.X + v.Y * n.Y except that a zero result is always +0, so the frame also replaces that scalar form wherever the result is compared, signed or taken Abs of.

Polylines (Polyline2D)

Build chain / loop shapes from lines and arcs:

var pts = new List<Vector2> { new(-40, 0) };
Polyline2D.AppendArc(pts, centerX: 34, centerY: 6, radius: 6, startDegrees: 270, endDegrees: 360, maxSegmentDegrees: 7.5f);
pts.Add(new Vector2(40, 30));
var loop = Polyline2D.RemoveDuplicates(pts);                  // drops repeats and a closing copy of the first point
world.CreateFixture(boundary, new PhysxFixtureDef2D { Shape = PhysxShape2D.Chain(loop, loop: true) });

Arcs (Trajectory2D)

Trajectory2D (namespace Altruist.TwoD.Numerics.Trajectory) draws designed (not physical) arcs for visuals and lobbed skills: ParabolicY(t, peak, riseEnd, hangEnd) (ease-out rise, hang, ease-in fall), ParabolicSample(start, end, t, ...) and the allocation-free ParabolicPolyline(start, end, ..., Span<Vector2> dest). For real projectile physics use Ballistics2D.