TypeScript Client Package

@altruist/sim2d is the TypeScript twin of the math, physics and gameplay helpers, for game clients that run the same simulation as the server (client-side prediction, replays, offline play, bots in the browser). Same names in camelCase, same semantics, same operation order.

  • Dependency-free: plain ESM TypeScript sources, no build step, no runtime dependencies.
  • Same layers: @altruist/sim2d/math, @altruist/sim2d/physics, @altruist/sim2d/gameplay (or everything from @altruist/sim2d), plus @altruist/sim2d/flow, the simulation building blocks and the state machine (on the math layer only).
  • Works on planck.js bodies directly: the body and world parameters are structural interfaces that a planck Body / World satisfies without a wrapper.

The package lives in the Altruist repository at Clients/TypeScript/sim2d.

Installing

Point a path alias at the sources. The sources import each other with .ts extensions, so a type-checked project enables allowImportingTsExtensions (allowed with noEmit, the usual setup with Vite):

// tsconfig.json
{
  "compilerOptions": {
    "allowImportingTsExtensions": true,
    "paths": { "@altruist/sim2d": ["../altruist/Clients/TypeScript/sim2d/src/index.ts"],
               "@altruist/sim2d/*": ["../altruist/Clients/TypeScript/sim2d/src/*/index.ts"] }
  }
}
// vite.config.ts
resolve: {
  alias: [
    { find: /^@altruist\/sim2d\/(math|physics|gameplay|flow)$/, replacement: fileURLToPath(new URL('../altruist/Clients/TypeScript/sim2d/src/$1/index.ts', import.meta.url)) },
    { find: /^@altruist\/sim2d$/, replacement: fileURLToPath(new URL('../altruist/Clients/TypeScript/sim2d/src/index.ts', import.meta.url)) },
  ],
},

Using It

import { Angle, DeterministicRandom, Scalar, VectorMath2D } from '@altruist/sim2d/math';
import { Ballistics2D, BodyMotion2D, rayCastClosest } from '@altruist/sim2d/physics';
import { GameplayVerbs2D as G } from '@altruist/sim2d/gameplay';

// Gameplay verbs take the body first (C# extension methods there).
G.jumpOff(car.body, surfaceNormal, cfg.jumpSpeed);
G.alignToSurface(car.body, groundNormal, cfg.alignRate);
G.fall(ball.body, gravity, dt, ball.gravityScale);

// Closest surface below, ignoring everything but tagged fixtures.
const below = rayCastClosest(world, from, to, (f) => f.getUserData()?.kind === 'surface', reach);
if (below.found) land(below.point, below.normal, below.distance);

// Same seed, same sequence as the C# DeterministicRandom.
const rng = new DeterministicRandom(seed);

Vectors are plain { x, y } objects (Vec2Like): any object with x and y is accepted (planck Vec2, Pixi points, literals), results are new objects, inputs are never mutated.

Body Interfaces

InterfaceMethods (planck names)
LinearBodygetLinearVelocity(), setLinearVelocity(v)
AngularBodygetAngle(), getAngularVelocity(), setAngularVelocity(w)
PositionedBodygetPosition()
Body2DLikeall of the above plus setTransform(p, angle), getWorldCenter(), getLocalPoint(p), getWorldVector(v), isAwake(), setAwake(flag)
ActivatableBodyisActive(), setActive(flag) (optional, for BodyState2D.restore(..., restoreActive))
RayCastWorld<F>rayCast(from, to, (fixture, point, normal, fraction) => number)

Each function asks only for what it uses (jumpOff needs a LinearBody), so lightweight test doubles work too.

API Map

C#TypeScript
Scalar.Approach, Lerp, Quantize, ...Scalar.approach, lerp, quantize, ...
Angle.Wrap, NearestEquivalent, ArriveRate, ...Angle.wrap, nearestEquivalent, arriveRate, ...
PiecewiseLinear.Evaluate / InversePiecewiseLinear.evaluate / inverse (points as [x, y] pairs)
DeterministicRandom (Next, Signed, Gaussian, Jitter, Chance, NextUInt, State)DeterministicRandom (next, signed, gaussian, jitter, chance, nextUint, state)
VectorMath2D.NormalizeOrZero(v, out len)VectorMath2D.normalizeOrZeroWithLength(v) → { direction, length }
VectorMath2D.Cross(w, r)VectorMath2D.crossScalar(w, r)
Direction2D, Rotation2D, Geometry2D, Polyline2Dsame names, camelCase members; Rotation2D.rotate(angle, v)
Aabb2D recordAabb2DLike object { minX, maxX, minY, maxY } + Aabb2D.contains / center / intersects / fromCorners
Velocity2D, Kinematics, Ballistics2Dsame; Ballistics2D.step(state, ...) mutates a { position, velocity } state (C# uses ref)
ContactNormals2D structContactNormals2D class
BodyMotionExtensions2D (extensions)BodyMotion2D.fn(body, ...)
BodyState2D.Capture / RestoreBodyState2D.capture / restore
ClosestRayHit2D, world.RayCastClosestClosestRayHit2D (hit.cast(world, from, to) or world.rayCast(from, to, hit.onHit)), rayCastClosest(world, ...)
GameplayVerbs2D (extensions)GameplayVerbs2D.verb(body, ...)
NormalFrame2D struct (TangentSide2D enum)NormalFrame2D class (NormalFrame2D.left(n) / right(n), side 'left' / 'right')
Geometry2D.ClassifyBoxSide → BoxSide2D enumGeometry2D.classifyBoxSide → 'front' | 'back' | 'top' | 'bottom'
Distance2D.Weighted / WeightedBetweenDistance2D.weighted / weightedBetween
Angle.Sector / SectorToward (double)Angle.sector / sectorToward
ContactImpact2D struct (Capture)ContactImpact2D class (ContactImpact2D.capture(point, normal, striker, target))
Ballistics2D.FirstZoneEntered → (Zone, Step)Ballistics2D.firstZoneEntered → { zone, step }
FacingFrame2D struct (UndersideEnds() → (Back, Front))FacingFrame2D class (undersideEnds() → { back, front }; the body also needs planck's getLocalVector)
ContactRouter2D (pairs by tag type), world.TouchingContacts<TA, TB>(), world.TouchingContactsOf<TOther>(body)ContactRouter2D (pairs by type guards, .attach(world)), touchingContacts(world, isA, isB, fn), touchingContactsOf(body, isOther, fn)
Altruist.Gaming.Flow blocks, StateMachine@altruist/sim2d/flow (see the name reference)

C# and TypeScript Results

Each TypeScript function evaluates the same expression in the same order as its C# twin. JavaScript numbers are doubles and C# uses float, so the twins agree to float precision, not bit for bit; what is bit-exact is each language against its own hand-written form of the formula. Two documented rounding differences: Scalar.quantize / roundHalfUp round exact halves up (Math.round) where C# Quantize rounds them to even.

Lengths and speeds are Math.sqrt(x * x + y * y) everywhere, the C# expression. Math.hypot computes the same length more carefully and can differ in the last bit, so client code that measures with Math.hypot keeps its own bits only while it keeps calling Math.hypot; switch both sides of a comparison together. Dot products are the scalar form a.x * b.x + a.y * b.y (C# frames and impacts use Vector2.Dot, which differs from that form only in the sign of a zero result). VectorMath2D.rotateDegrees takes its radians as degrees * (π / 180) (fromPolarDegrees), the C# RotateDegrees as (degrees * π) / 180.

DeterministicRandom is the exception: its integer sequence (nextUint, state) and its next() doubles are identical in both languages (32-bit arithmetic via Math.imul and >>> 0); C# Signed() / Gaussian() round the same double to float.

Tests

The package carries its own tests (Node's built-in runner, no dependencies):

cd Clients/TypeScript/sim2d
node --test "tests/*.test.ts"     # Node 23.6+ runs TypeScript directly

The random-sequence tests assert the same golden values as the C# tests.