@lagless/math
v0.0.36
Published
Provides deterministic mathematical operations and 2D vector algebra for the Lagless ECS framework. Wraps the `@lagless/deterministic-math` WASM module to guarantee identical floating-point results across all platforms (browsers, Node.js, operating system
Readme
@lagless/math
1. Responsibility & Context
Provides deterministic mathematical operations and 2D vector algebra for the Lagless ECS framework. Wraps the @lagless/deterministic-math WASM module to guarantee identical floating-point results across all platforms (browsers, Node.js, operating systems), which is essential for rollback netcode where clients must simulate identically. All trigonometric functions (sin, cos, atan2) and square root operations use the WASM implementation to avoid platform-specific IEEE 754 edge cases.
2. Architecture Role
Foundation layer — sits directly above @lagless/deterministic-math and provides math primitives used throughout the stack. No dependencies on other Lagless libraries.
Downstream consumers:
@lagless/core— ECS systems useMathOpsandVector2for physics, movement, and collision calculationscircle-sumo-simulation— game logic relies on deterministic vector operations for player movement and collisions
Upstream dependencies:
@lagless/deterministic-math— WASM module providingdm_sin,dm_cos,dm_atan2,dm_sqrt
3. Public API
MathOps
Static utility class wrapping WASM-based deterministic math operations:
MathOps.init(): Promise<void>— MUST be called before any other operations. Initializes the WASM module asynchronously.MathOps.PI,MathOps.PI_2,MathOps.PI_HALF— Mathematical constants (π, 2π, π/2)MathOps.Deg2Rad,MathOps.Rad2Deg— Angle conversion constantsMathOps.sin(angle: number): number— Deterministic sine (radians)MathOps.cos(angle: number): number— Deterministic cosine (radians)MathOps.atan2(y: number, x: number): number— Deterministic arctangent2 (radians)MathOps.sqrt(value: number): number— Deterministic square rootMathOps.clamp(value: number, min: number, max: number): number— Clamp value to rangeMathOps.clamp01(value: number): number— Clamp to [0, 1]MathOps.lerp(a: number, b: number, t: number): number— Linear interpolationMathOps.lerpAngle(a: number, b: number, t: number): number— Angle interpolation (shortest path around circle)MathOps.normalizeAngle(angle: number): number— Normalize angle to (-π, π]MathOps.smoothRotate(rotation: number, targetRotation: number, rotationSpeed: number): number— Smooth rotation with speed limitMathOps.repeat(t: number, length: number): number— Wrap value to [0, length)
Vector2
2D vector class with comprehensive algebra operations. All operations come in three flavors:
...InPlace()— Mutatesthisand returns it (zero allocation)...ToRef(ref)— Writes result torefparameter and returns it (zero allocation)...ToNew()— Allocates and returns a newVector2instance
Constructor & Constants:
new Vector2(x?: number, y?: number)— Create vector (defaults to 0, 0)Vector2.ZERO,Vector2.ONE,Vector2.UNIT_X,Vector2.UNIT_Y— Static readonly constantsVector2.UP,Vector2.DOWN,Vector2.LEFT,Vector2.RIGHT— Directional constantsVector2.EPSILON = 1e-8— Epsilon for safe normalization/comparisons
Basic Operations:
setInPlace(x, y),copyFrom(other),copyToRef(ref),clone()— Setters and copyingaddToNew(other),addToRef(other, ref),addInPlace(other)— AdditionsubToNew(other),subToRef(other, ref),subInPlace(other)— SubtractionmulToNew(other),mulToRef(other, ref),mulInPlace(other)— Component-wise multiplydivToNew(other),divToRef(other, ref),divInPlace(other)— Component-wise dividescaleToNew(s),scaleToRef(s, ref),scaleInPlace(s)— Scalar multiplicationnegateToNew(),negateToRef(ref),negateInPlace()— NegateabsToNew(),absToRef(ref),absInPlace()— Absolute value (component-wise)
Min/Max/Clamp:
minToRef(other, ref),minInPlace(other)— Component-wise minimummaxToRef(other, ref),maxInPlace(other)— Component-wise maximumclampToNew(min, max),clampToRef(min, max, ref),clampInPlace(min, max)— Component-wise clamp
Metrics:
lengthSquared(): number,length(): number— Magnitude (usesMathOps.sqrt)distanceSquaredTo(other): number,distanceTo(other): number— Distance to another vectordot(other): number— Dot productcrossZ(other): number— 2D cross product (Z component)
Normalization:
normalizedToNew(),normalizeToRef(ref),normalizeInPlace()— Normalize to unit length (returns zero vector if length < EPSILON)
Angles & Rotation:
angle(): number— Angle from +X axis in radians (-π, π]angleTo(other): number— Smallest signed angle to another vectorrotatedToNew(angle),rotateToRef(angle, ref),rotateInPlace(angle)— Rotate around originrotatedAroundToNew(pivot, angle),rotateAroundToRef(pivot, angle, ref),rotateAroundInPlace(pivot, angle)— Rotate around pivotrotateTowardsInPlace(target, maxDelta)— Rotate towards target by at most maxDelta radians
Projection & Reflection:
projectOntoToNew(normal),projectOntoToRef(normal, ref),projectOntoInPlace(normal)— Project onto normalreflectToNew(normal),reflectToRef(normal, ref),reflectInPlace(normal)— Reflect across normal
Interpolation:
lerpToNew(to, t),lerpToRef(to, t, ref),lerpInPlace(to, t)— Linear interpolationnlerpToNew(to, t),nlerpToRef(to, t, ref),nlerpInPlace(to, t)— Normalized lerp (useful for directions)
Perpendiculars:
perpLeftToNew(),perpLeftToRef(ref),perpLeftInPlace()— Left perpendicular (+90° rotation)perpRightToNew(),perpRightToRef(ref),perpRightInPlace()— Right perpendicular (-90° rotation)
Length Clamping:
clampLengthToNew(minLen, maxLen),clampLengthToRef(minLen, maxLen, ref),clampLengthInPlace(minLen, maxLen)— Clamp vector length
Equality:
equals(other): boolean— Exact equalityapproxEquals(other, eps?): boolean— Approximate equality (default epsilon = 1e-8)
Serialization:
toArray(out?, offset?): number[]— Convert to array[x, y]Vector2.fromArray(arr, offset?): Vector2— Create from arrayVector2.fromArrayToRef(arr, ref, offset?): Vector2— Read from array into ref
Construction Helpers:
Vector2.fromAngle(angle, length?): Vector2— Create vector from angle and lengthVector2.fromAngleToRef(angle, ref, length?): Vector2— Create from angle into refVector2.minToRef(a, b, ref)— Component-wise min of two vectorsVector2.maxToRef(a, b, ref)— Component-wise max of two vectors
IVector2Like
Interface for objects with x and y number properties. Used for duck-typed vector compatibility.
Vector2 Buffers
Pre-allocated Vector2 instances for temporary calculations (avoids allocation in hot loops):
VECTOR2_BUFFER_1throughVECTOR2_BUFFER_10— Reusable vector instances
4. Preconditions
MathOps.init()MUST be called before using anyMathOpsfunctions orVector2operations that depend on trigonometry/sqrt. This initializes the WASM module. Failure to call this results in undefined behavior or crashes.- Async initialization: Call
await MathOps.init()during application startup before the ECS runner starts.
5. Postconditions
- After
MathOps.init()completes, all math operations produce deterministic results across platforms. Vector2operations usingInPlaceandToRefvariants produce zero garbage (no allocations).- All angle operations work in radians (not degrees).
6. Invariants & Constraints
- Determinism guarantee:
MathOpsfunctions produce bit-identical results on all platforms (Windows/Mac/Linux, Chrome/Firefox/Safari/Node.js). This is critical for rollback netcode. - Radians-only: All angle parameters and return values are in radians. Use
MathOps.Deg2Rad/MathOps.Rad2Degfor conversion. - Epsilon safety:
Vector2normalization and division operations check for near-zero lengths usingVector2.EPSILON = 1e-8to avoid NaN/Infinity. - Static readonly constants:
Vector2.ZERO,Vector2.ONE, etc. are readonly and MUST NOT be mutated. They are shared instances. - InPlace/ToRef/ToNew pattern: Methods ending in
InPlacemutatethis, methods ending inToRefmutate a reference parameter, methods ending inToNewallocate a new instance. Never mix expectations.
7. Safety Notes (AI Agent)
DO NOT
- DO NOT use
Math.sin,Math.cos,Math.atan2,Math.sqrtdirectly — These produce platform-dependent results. Always useMathOps.sin,MathOps.cos,MathOps.atan2,MathOps.sqrt. - DO NOT mutate static readonly constants (
Vector2.ZERO,Vector2.ONE, etc.) — These are shared instances. Clone before mutating. - DO NOT forget to call
MathOps.init()— Calling WASM functions before initialization causes crashes. - DO NOT mix radians and degrees — All angle operations use radians. Convert explicitly if needed.
- DO NOT allocate
Vector2in hot loops — UseVECTOR2_BUFFER_*constants or...ToRef()methods for zero-allocation operations. - DO NOT use
Vector2operations inside ECS systems without understanding allocation — Systems run every tick (60 FPS). PreferInPlaceandToRefvariants.
Common Mistakes
- Using
Math.sqrt()instead ofMathOps.sqrt()in vector normalization → platform-specific desyncs - Forgetting
await MathOps.init()during startup → WASM module not loaded, crashes at first trig call - Mutating
Vector2.ZERO.x = 5→ breaks all future uses ofVector2.ZERO(shared instance) - Mixing degrees and radians → rotation by 90 instead of
MathOps.PI_HALFrotates by ~5157 degrees
8. Usage Examples
Basic MathOps
import { MathOps } from '@lagless/math';
// MUST call init before any usage
await MathOps.init();
// Deterministic trig
const angle = MathOps.PI_HALF;
const s = MathOps.sin(angle); // 1.0 (deterministic)
const c = MathOps.cos(angle); // ~0.0 (deterministic)
// Angle normalization
const normalized = MathOps.normalizeAngle(MathOps.PI * 3); // -π
// Lerp and clamp
const interpolated = MathOps.lerp(0, 100, 0.5); // 50
const clamped = MathOps.clamp(150, 0, 100); // 100Vector2 Basics
import { Vector2, MathOps } from '@lagless/math';
await MathOps.init();
// Create vectors
const a = new Vector2(3, 4);
const b = new Vector2(1, 2);
// Length and distance
console.log(a.length()); // 5.0 (uses MathOps.sqrt)
console.log(a.distanceTo(b)); // 2.828...
// Addition (three flavors)
const sum1 = a.addToNew(b); // New instance: (4, 6)
const sum2 = a.addInPlace(b); // Mutates a, returns a: (4, 6)
const sum3 = Vector2.ZERO.clone();
a.addToRef(b, sum3); // Writes to sum3: (4, 6)
// Normalization
const dir = new Vector2(3, 4).normalizeInPlace(); // (0.6, 0.8)Zero-Allocation Pattern (Hot Loops)
import { Vector2, VECTOR2_BUFFER_1, VECTOR2_BUFFER_2 } from '@lagless/math';
// Inside an ECS system (runs 60 times per second)
function updateVelocity(position: Vector2, target: Vector2, speed: number) {
// Use pre-allocated buffers to avoid GC pressure
const direction = VECTOR2_BUFFER_1;
const delta = VECTOR2_BUFFER_2;
target.subToRef(position, delta); // delta = target - position
delta.normalizeToRef(direction); // direction = normalize(delta)
direction.scaleToRef(speed, delta); // delta = direction * speed
position.addInPlace(delta); // position += delta
// No allocations! VECTOR2_BUFFER_* are reused every frame.
}Angle and Rotation
import { Vector2, MathOps } from '@lagless/math';
await MathOps.init();
const v = new Vector2(1, 0);
console.log(v.angle()); // 0 (pointing right)
v.rotateInPlace(MathOps.PI_HALF);
console.log(v.angle()); // π/2 (pointing up)
// Rotate towards target
const target = new Vector2(-1, 0);
v.rotateTowardsInPlace(target, MathOps.Deg2Rad * 45); // Rotate by at most 45°9. Testing Guidance
Framework: Vitest
Running tests:
# From monorepo root
nx test math
# Or with direct runner
npm test -- libs/mathExisting test patterns:
libs/math/src/lib/math.spec.ts— Verifies WASM determinism by comparingMathOpsoutput to standardMathfunctions (within 10 decimal places)- Tests call
await MathOps.init()before assertions - Uses
toBeCloseTo(expected, precision)for floating-point comparisons
When adding tests:
- Always call
await MathOps.init()in the test setup or at the start of the test - Use
toBeCloseTo()for floating-point assertions (exact equality fails due to rounding) - Test
InPlace,ToRef, andToNewvariants separately to verify allocation behavior
10. Change Checklist
When modifying this module:
- Verify determinism: If changing math operations, test on multiple browsers and Node.js
- Maintain three-variant pattern: New
Vector2operations should provideInPlace,ToRef, andToNewmethods - Update tests: Add test coverage for new operations
- Check allocation: Profile to ensure
ToRefandInPlacemethods don't allocate - Update this README: Document new APIs in Public API section
- Preserve radians-only convention: Do not add degree-based APIs
- DO NOT replace WASM calls with standard Math: This breaks determinism
11. Integration Notes
Used By
@lagless/core—Vector2is used extensively forTransform2dandVelocity2dcomponents. Systems useMathOpsfor deterministic physics calculations.circle-sumo-simulation— All game physics (collision, movement, impulses) rely onVector2andMathOpsfor deterministic simulation.
Common Integration Patterns
ECS Component Integration:
// Transform2d component stores position and rotation
// Systems use Vector2 operations for movement
class MovementSystem {
run(dt: number) {
for (const entityId of this.filter) {
const transform = this.transform2d.unsafe.position[entityId]; // Vector2
const velocity = this.velocity2d.unsafe.velocity[entityId]; // Vector2
// Zero-allocation update
velocity.scaleToRef(dt, VECTOR2_BUFFER_1);
transform.addInPlace(VECTOR2_BUFFER_1);
}
}
}Initialization Order:
// In your ECS runner or app entrypoint:
async function main() {
await MathOps.init(); // FIRST: Initialize WASM
const runner = new MyECSRunner(config); // THEN: Start ECS
runner.start();
}12. Appendix
Vector2 Allocation Patterns
Understanding the three-variant pattern is critical for performance:
| Variant | Allocates? | Use Case |
|---------|-----------|----------|
| ...ToNew() | Yes | One-time calculations, initialization, readable code outside hot loops |
| ...InPlace() | No | Mutate this directly. Use when you own the vector and want to update it. |
| ...ToRef(ref) | No | Write to an existing vector. Use in hot loops with pre-allocated buffers. |
Example comparison:
// ALLOCATES (avoid in 60 FPS loops)
const result1 = a.addToNew(b);
// ZERO ALLOCATION (mutates a)
const result2 = a.addInPlace(b); // a is now (a+b)
// ZERO ALLOCATION (writes to temp)
const temp = new Vector2(); // Allocated ONCE outside loop
for (let i = 0; i < 1000; i++) {
a.addToRef(b, temp); // Reuses temp, no allocation
}VECTOR2_BUFFER_* Constants
Ten pre-allocated Vector2 instances for temporary calculations:
import { VECTOR2_BUFFER_1, VECTOR2_BUFFER_2 } from '@lagless/math';
function collisionCheck(a: Vector2, b: Vector2): boolean {
const delta = VECTOR2_BUFFER_1;
b.subToRef(a, delta); // delta = b - a
return delta.lengthSquared() < 4; // collision if distance < 2
}Safety: These are module-level singletons. Do NOT use the same buffer recursively (e.g., calling a function that uses BUFFER_1 while you're also using BUFFER_1). Use different buffer indices for nested operations.
Deterministic Math Implementation
The @lagless/deterministic-math WASM module uses C implementations of trigonometric functions and square root to guarantee bit-identical results across platforms. JavaScript's native Math functions delegate to platform-specific libm implementations, which differ between browsers and operating systems. For rollback netcode, even a 1-bit difference in float results causes divergence over time.
Performance: WASM math functions are ~2-3x slower than native Math functions, but this overhead is negligible compared to typical game logic. The determinism guarantee is worth the cost.
