4.4 KiB
Game Quantization Helpers
(no submodules)
Purpose
Coordinate-rounding helpers for game-state vectors and rotations. The functions in this module trade absolute precision for compact wire representations: quantizePosition({ x: 1.234, y: 5.678, z: 9.012 }, 0.01) rounds each component to two decimal places before packing, halving the bit-width versus a raw float. This is the standard tactic for bandwidth-constrained multiplayer games — a typical ±1024 m world quantized to 0.01 m precision packs into ~17 bits per axis instead of 32 bits.
The 2-D and 3-D variants exist because some games (top-down, side-scrolling) work in Vector2 and would otherwise pay the cost of a wasted z component. quantizeRotation separately handles yaw and pitch with their own precisions; interpolateAngle handles the 360°/0° wrap so a transition from 350° to 10° interpolates through the short arc, not the long one. validateRange is a defensive check used at API boundaries.
Features
quantizePosition(position, precision = 0.01)— round aVector3to a precision divisorquantizePosition2D(position, precision = 0.01)— round aVector2quantizeRotation(rotation, yawPrecision = 1, pitchPrecision = 1)— round yaw + pitchinterpolateAngle(a, b, t)— interpolate angles in degrees, taking the short arc through the wrapvalidateRange(value, min, max, name?)— throw if out of range, with an optional field name for the error
Usage: Basic Example
import { quantizePosition, quantizeRotation } from "@suaveplan/bitpacker-game";
const pos = quantizePosition({ x: 1.234, y: 5.678, z: 9.012 }, 0.01);
// { x: 1.23, y: 5.68, z: 9.01 }
const rot = quantizeRotation({ yaw: 90.55, pitch: -45.4 }, 1, 1);
// { yaw: 91, pitch: -45 }
Usage: Advanced Example
import {
quantizePosition,
quantizeRotation,
interpolateAngle,
validateRange,
} from "@suaveplan/bitpacker-game";
function tick(prev: { yaw: number }, target: { yaw: number }, t: number) {
validateRange(t, 0, 1, "interpolation t");
return { yaw: interpolateAngle(prev.yaw, target.yaw, t) };
}
// Quantize before pack to match the schema's fixed-point precision exactly:
function preparePacket(position: { x: number; y: number; z: number }) {
const q = quantizePosition(position, 0.01);
validateRange(q.x, -1024, 1024, "x");
validateRange(q.y, -1024, 1024, "y");
validateRange(q.z, -128, 128, "z");
return q;
}
API Reference
quantizePosition(position: Vector3, precision = 0.01): Vector3
Returns a new Vector3 with each component rounded to precision (e.g. 0.01 for two decimal places, 1 for whole units). The result is the nearest grid point on a precision-divisor lattice.
quantizePosition2D(position: Vector2, precision = 0.01): Vector2
Same as quantizePosition for a 2-D vector. No z component.
quantizeRotation(rotation: Rotation, yawPrecision = 1, pitchPrecision = 1): Rotation
Returns a new Rotation with yaw and pitch rounded to their respective precisions. The two axes have separate precisions because yaw typically needs less granularity than pitch (or vice versa).
interpolateAngle(a: number, b: number, t: number): number
Returns the interpolation between a and b taking the short arc through the 360°/0° wrap. t ∈ [0, 1]. Equivalent to slerp on the unit circle for two-axis cases.
validateRange(value: number, min: number, max: number, name?: string): void
Throws if value is outside [min, max]. The optional name is included in the error message to identify the offending field.
Implementation Notes
- Quantization is round-to-nearest-grid.
Math.round(value / precision) * precisionproduces banker's rounding behaviour at the exact half. Consumers that need round-half-up should pre-add a half-precision offset before quantizing. - No clamp. These helpers do not clamp out-of-range values; pair them with
validateRangeif your wire format has a hard cap. Quantizing without clamping can produce values outside the schema's bit-width and cause pack failures. interpolateAngleis not unwrap-aware. It takes the short arc per pair; chains of three or more interpolated angles can drift unless the consumer normalizes between calls.- Defensive-but-cheap.
validateRangeisO(1)and emits a clear error message including the field name; it's appropriate at every API boundary, not just hot-path entries.