Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
# @suaveplan/bitpacker-game - Bitpacker game plugin
## Summary
Game state utilities: quantization, prediction, interpolation, and client-server reconciliation.
<!-- begin:auto-docs -->
## Documentation tree
- [bitpacker](./src/bitpacker/bitpacker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bitpacker](./src/bitpacker/bitpacker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [integration-manager](./src/integration-manager/integration-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [integration-manager](./src/integration-manager/integration-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [index](./src/interpolation/index.md) — **Path:** `src/interpolation/interpolation.ts` **Export:** `GameInterpolator`
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [prediction](./src/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - rotation-predictor — **Path:** `bitpacker-game/src/…
- [index](./src/prediction/index.md) — **Path:** `src/prediction/` **Exports:** `PositionPredictor`, `RotationPredictor`, motion math, Kalman filter, predicti…
- [kalman-filter](./src/prediction/kalman-filter.md) — **Path:** `bitpacker-game/src/prediction/kalman-filter.ts` **Export:** See source for details.
- [motion-math](./src/prediction/motion-math.md) — **Path:** `bitpacker-game/src/prediction/motion-math.ts` **Export:** See source for details.
- [position-predictor](./src/prediction/position-predictor.md) — **Path:** `bitpacker-game/src/prediction/position-predictor.ts` **Export:** See source for details.
- [prediction-algorithms](./src/prediction/prediction-algorithms.md) — **Path:** `bitpacker-game/src/prediction/prediction-algorithms.ts` **Export:** See source for details.
- [prediction](./src/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - rotation-predictor — **Path:** `bitpacker-game/src/…
- [rotation-predictor](./src/prediction/rotation-predictor.md) — **Path:** `bitpacker-game/src/prediction/rotation-predictor.ts` **Export:** See source for details.
- [quantization](./src/quantization/quantization.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [quantization](./src/quantization/quantization.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [reconciliation](./src/reconciliation/reconciliation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [index](./src/reconciliation/index.md) — **Path:** `src/reconciliation/reconciliation.ts` **Export:** `StateReconciliation`
- [reconciliation](./src/reconciliation/reconciliation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [register-schemas](./src/register-schemas/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-game` package. Exposes `register…
- [register-schemas](./src/register-schemas/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-game` package. Exposes `register…
- [schema-factory](./src/schema-factory/schema-factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schema-factory](./src/schema-factory/schema-factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [state-manager](./src/state-manager/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [state-manager](./src/state-manager/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types.schema](./src/types.schema/types.schema.md) — Zod schemas for the public configuration types of `@suaveplan/bitpacker-game`: the three sub-options bundles (`Predicti…
- [types.schema](./src/types.schema/types.schema.md) — Zod schemas for the public configuration types of `@suaveplan/bitpacker-game`: the three sub-options bundles (`Predicti…
- [index](./src/index.md) — **Path:** `bitpacker-game/src/index.ts` **Export:** See source for details.
- [types](./src/types.md) — **Path:** `src/types.ts` **Export:** All game-related type definitions and enums
<!-- end:auto-docs --> - [bitpacker](./src/bitpacker/bitpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [integration-manager](./src/integration-manager/integration-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [integration-manager](./src/integration-manager/integration-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/interpolation/index.md) — **Path:** `src/interpolation/interpolation.ts` **Export:** `GameInterpolator`
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [prediction](./src/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_- rotation-predictor — **Path:** `bitpacker-game/src/…
- [index](./src/prediction/index.md) — **Path:** `src/prediction/` **Exports:** `PositionPredictor`, `RotationPredictor`, motion math, Kalman filter, predicti…
- [kalman-filter](./src/prediction/kalman-filter.md) — **Path:** `bitpacker-game/src/prediction/kalman-filter.ts` **Export:** See source for details.
- [motion-math](./src/prediction/motion-math.md) — **Path:** `bitpacker-game/src/prediction/motion-math.ts` **Export:** See source for details.
- [position-predictor](./src/prediction/position-predictor.md) — **Path:** `bitpacker-game/src/prediction/position-predictor.ts` **Export:** See source for details.
- [prediction-algorithms](./src/prediction/prediction-algorithms.md) — **Path:** `bitpacker-game/src/prediction/prediction-algorithms.ts` **Export:** See source for details.
- [prediction](./src/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_- rotation-predictor — **Path:** `bitpacker-game/src/…
- [rotation-predictor](./src/prediction/rotation-predictor.md) — **Path:** `bitpacker-game/src/prediction/rotation-predictor.ts` **Export:** See source for details.
- [quantization](./src/quantization/quantization.md) — <!-- begin:auto-docs --> _(no submodules)_- [quantization](./src/quantization/quantization.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation](./src/reconciliation/reconciliation.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/reconciliation/index.md) — **Path:** `src/reconciliation/reconciliation.ts` **Export:** `StateReconciliation`
- [reconciliation](./src/reconciliation/reconciliation.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-factory](./src/schema-factory/schema-factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-factory](./src/schema-factory/schema-factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-manager](./src/state-manager/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-manager](./src/state-manager/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-game/src/index.ts` **Export:** See source for details.
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-game` package. Exposes `register…
- [types](./src/types.md) — **Path:** `src/types.ts` **Export:** All game-related type definitions and enums
- [types.schema](./src/types.schema.md) — Zod schemas for the public configuration types of `@suaveplan/bitpacker-game`: the three sub-options bundles (`Predicti…
- [bitpacker](./src/bitpacker/bitpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [integration-manager](./src/integration-manager/integration-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [integration-manager](./src/integration-manager/integration-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/interpolation/index.md) — **Path:** `src/interpolation/interpolation.ts` **Export:** `GameInterpolator`
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [prediction](./src/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_- rotation-predictor — **Path:** `bitpacker-game/src/…
- [index](./src/prediction/index.md) — **Path:** `src/prediction/` **Exports:** `PositionPredictor`, `RotationPredictor`, motion math, Kalman filter, predicti…
- [kalman-filter](./src/prediction/kalman-filter.md) — **Path:** `bitpacker-game/src/prediction/kalman-filter.ts` **Export:** See source for details.
- [motion-math](./src/prediction/motion-math.md) — **Path:** `bitpacker-game/src/prediction/motion-math.ts` **Export:** See source for details.
- [position-predictor](./src/prediction/position-predictor.md) — **Path:** `bitpacker-game/src/prediction/position-predictor.ts` **Export:** See source for details.
- [prediction-algorithms](./src/prediction/prediction-algorithms.md) — **Path:** `bitpacker-game/src/prediction/prediction-algorithms.ts` **Export:** See source for details.
- [prediction](./src/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_- rotation-predictor — **Path:** `bitpacker-game/src/…
- [rotation-predictor](./src/prediction/rotation-predictor.md) — **Path:** `bitpacker-game/src/prediction/rotation-predictor.ts` **Export:** See source for details.
- [quantization](./src/quantization/quantization.md) — <!-- begin:auto-docs --> _(no submodules)_- [quantization](./src/quantization/quantization.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation](./src/reconciliation/reconciliation.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/reconciliation/index.md) — **Path:** `src/reconciliation/reconciliation.ts` **Export:** `StateReconciliation`
- [reconciliation](./src/reconciliation/reconciliation.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-factory](./src/schema-factory/schema-factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-factory](./src/schema-factory/schema-factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-manager](./src/state-manager/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-manager](./src/state-manager/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-game/src/index.ts` **Export:** See source for details.
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-game` package. Exposes `register…
- [types](./src/types.md) — **Path:** `src/types.ts` **Export:** All game-related type definitions and enums
- [types.schema](./src/types.schema.md) — Zod schemas for the public configuration types of `@suaveplan/bitpacker-game`: the three sub-options bundles (`Predicti…
- [bitpacker](./src/bitpacker/bitpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [integration-manager](./src/integration-manager/integration-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [integration-manager](./src/integration-manager/integration-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/interpolation/index.md) — **Path:** `src/interpolation/interpolation.ts` **Export:** `GameInterpolator`
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [prediction](./src/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_- rotation-predictor — **Path:** `bitpacker-game/src/…
- [index](./src/prediction/index.md) — **Path:** `src/prediction/` **Exports:** `PositionPredictor`, `RotationPredictor`, motion math, Kalman filter, predicti…
- [kalman-filter](./src/prediction/kalman-filter.md) — **Path:** `bitpacker-game/src/prediction/kalman-filter.ts` **Export:** See source for details.
- [motion-math](./src/prediction/motion-math.md) — **Path:** `bitpacker-game/src/prediction/motion-math.ts` **Export:** See source for details.
- [position-predictor](./src/prediction/position-predictor.md) — **Path:** `bitpacker-game/src/prediction/position-predictor.ts` **Export:** See source for details.
- [prediction-algorithms](./src/prediction/prediction-algorithms.md) — **Path:** `bitpacker-game/src/prediction/prediction-algorithms.ts` **Export:** See source for details.
- [prediction](./src/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_- rotation-predictor — **Path:** `bitpacker-game/src/…
- [rotation-predictor](./src/prediction/rotation-predictor.md) — **Path:** `bitpacker-game/src/prediction/rotation-predictor.ts` **Export:** See source for details.
- [quantization](./src/quantization/quantization.md) — <!-- begin:auto-docs --> _(no submodules)_- [quantization](./src/quantization/quantization.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation](./src/reconciliation/reconciliation.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/reconciliation/index.md) — **Path:** `src/reconciliation/reconciliation.ts` **Export:** `StateReconciliation`
- [reconciliation](./src/reconciliation/reconciliation.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-factory](./src/schema-factory/schema-factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-factory](./src/schema-factory/schema-factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-manager](./src/state-manager/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-manager](./src/state-manager/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-game/src/index.ts` **Export:** See source for details.
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-game` package. Exposes `register…
- [types](./src/types.md) — **Path:** `src/types.ts` **Export:** All game-related type definitions and enums
- [types.schema](./src/types.schema.md) — Zod schemas for the public configuration types of `@suaveplan/bitpacker-game`: the three sub-options bundles (`Predicti…
- [bitpacker](./src/bitpacker/bitpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [integration-manager](./src/integration-manager/integration-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [integration-manager](./src/integration-manager/integration-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [index](./src/interpolation/index.md) — **Path:** `src/interpolation/interpolation.ts` **Export:** `GameInterpolator`
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [prediction](./src/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_ - [index](./src/prediction/index.md) — **Path:** `src/prediction/` **Exports:** `PositionPredictor`, `RotationPredictor`, motion math, Kalman filter, predicti…
- [kalman-filter](./src/prediction/kalman-filter.md) — **Path:** `bitpacker-game/src/prediction/kalman-filter.ts` **Export:** See source for details.
- [motion-math](./src/prediction/motion-math.md) — **Path:** `bitpacker-game/src/prediction/motion-math.ts` **Export:** See source for details.
- [position-predictor](./src/prediction/position-predictor.md) — **Path:** `bitpacker-game/src/prediction/position-predictor.ts` **Export:** See source for details.
- [prediction-algorithms](./src/prediction/prediction-algorithms.md) — **Path:** `bitpacker-game/src/prediction/prediction-algorithms.ts` **Export:** See source for details.
- [prediction](./src/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_ - [rotation-predictor](./src/prediction/rotation-predictor.md) — **Path:** `bitpacker-game/src/prediction/rotation-predictor.ts` **Export:** See source for details.
- [quantization](./src/quantization/quantization.md) — <!-- begin:auto-docs --> _(no submodules)_ - [quantization](./src/quantization/quantization.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation](./src/reconciliation/reconciliation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [index](./src/reconciliation/index.md) — **Path:** `src/reconciliation/reconciliation.ts` **Export:** `StateReconciliation`
- [reconciliation](./src/reconciliation/reconciliation.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-factory](./src/schema-factory/schema-factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schema-factory](./src/schema-factory/schema-factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-manager](./src/state-manager/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [state-manager](./src/state-manager/state-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-game/src/index.ts` **Export:** See source for details.
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-game` package. Exposes `register…
- [types](./src/types.md) — **Path:** `src/types.ts` **Export:** All game-related type definitions and enums
- [types.schema](./src/types.schema.md) — Zod schemas for the public configuration types of `@suaveplan/bitpacker-game`: the three sub-options bundles (`Predicti…
## Install
```bash
bun add @suaveplan/bitpacker-game
```
## Usage
```typescript
import {
GameStateManager,
quantizeVector,
dequantizeVector,
PositionPredictor,
RotationPredictor,
GameInterpolator,
StateReconciliation,
PredictionAlgorithm,
InterpolationMethod,
} from "@suaveplan/bitpacker-game";
// Quantization for network compression
const quantized = quantizeVector({ x: 0.5, y: -0.25, z: 0.75 }, 16);
const restored = dequantizeVector(quantized, 16);
// Position prediction
const predictor = new PositionPredictor({ algorithm: PredictionAlgorithm.ADAPTIVE });
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
const futurePos = predictor.predictPosition(100); // predicted position at t=100ms
// Interpolation
const interp = new GameInterpolator({ method: InterpolationMethod.HERMITE });
const midPos = interp.interpolatePosition({ x: 0, y: 0, z: 0 }, { x: 10, y: 0, z: 0 }, 0.5);
// Client-server reconciliation
const reconciliation = new StateReconciliation();
const seq = reconciliation.processInput({ moveX: 1, moveY: 0, rotateYaw: 0, rotatePitch: 0, actions: [] }, Date.now());
```
## Modules
- `src/index.ts` — `GameStateManager`, `quantizeVector`, `dequantizeVector`
- `src/types.ts` — `Vector3`, `Rotation`, `PlayerState`, `PredictionAlgorithm`, `InterpolationMethod`, etc.
- `src/prediction/` — `PositionPredictor`, `RotationPredictor`, motion math, Kalman filter, prediction algorithms
- `src/interpolation/` — `GameInterpolator` with 9 interpolation methods
- `src/reconciliation/` — `StateReconciliation` for client prediction + server reconciliation

View File

@@ -0,0 +1,61 @@
{
"name": "@suaveplan/bitpacker-game",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Bitpacker game plugin",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./register-schemas": {
"import": {
"types": "./dist/register-schemas/index.d.ts",
"default": "./dist/register-schemas/index.js"
},
"require": {
"types": "./dist/register-schemas/index.d.ts",
"default": "./dist/register-schemas/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/"
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/bitpacker": "workspace:^",
"@suaveplan/bitpacker-network": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/schema-registry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,82 @@
# GameBitPacker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GameBitPacker` is the top-level facade for the `@suaveplan/bitpacker-game` package: a `BitPacker` subclass that composes prediction, interpolation, reconciliation, network simulation, and quantization helpers into one game-ready binary serializer. Most consumers construct one `GameBitPacker` per game session and route every state pack/unpack through it; the four subsystems are wired up internally based on the constructor's `enable*` flags so a consumer doesn't have to chain managers manually.
The class is generic on the schema type (`T extends BitPackerSchema`), so a game's player schema, world schema, and input schema can each have their own typed `GameBitPacker<PlayerSchema>` / `GameBitPacker<WorldSchema>` instance. Internally the class delegates to `GameStateManager` for state ownership, `GameIntegrationManager` for the cross-subsystem orchestration, and the `GameQuantization` namespace for vector/rotation rounding.
## Features
- Inherits everything from `BitPacker` (pack, unpack, schema registry, strategy chain)
- `enablePrediction` / `enableInterpolation` / `enableReconciliation` / `enableNetworkSimulation` toggles
- Composes `PositionPredictor` + `RotationPredictor` + `GameInterpolator` + `StateReconciliation` + `NetworkSimulator`
- Quantization helpers for position, rotation, and 2-D / 3-D vectors via the `GameQuantization` namespace
- Input/state sequence tracking via `GameStateManager` (4-bit sequence wraps at `MAX_SEQUENCE = 15`)
## Usage: Basic Example
```ts
import { GameBitPacker, PLAYER_STATE_SCHEMA } from "@suaveplan/bitpacker-game";
const bp = new GameBitPacker(PLAYER_STATE_SCHEMA, {
enablePrediction: true,
enableInterpolation: true,
});
const packed = bp.pack(playerState);
const restored = bp.unpack(packed);
```
## Usage: Advanced Example
```ts
import {
GameBitPacker,
PLAYER_STATE_SCHEMA,
PredictionAlgorithm,
} from "@suaveplan/bitpacker-game";
const bp = new GameBitPacker(PLAYER_STATE_SCHEMA, {
enablePrediction: true,
enableInterpolation: true,
enableReconciliation: true,
enableNetworkSimulation: true,
predictionOptions: {
algorithm: PredictionAlgorithm.KALMAN,
historySize: 8,
},
reconciliationOptions: {
inputBufferSize: 60,
stateBufferSize: 120,
enableExtrapolation: true,
},
networkSimulationOptions: {
latencyMin: 30,
latencyMax: 80,
packetLoss: 1,
},
});
bp.pushState(serverFrame);
bp.applyPhysics((state, input, dt) => /* simulate */ state, currentInput, 1 / 60);
const interpolated = bp.interpolate(prev, next, 0.5);
```
## API Reference
### `class GameBitPacker<T extends BitPackerSchema> extends BitPacker<T>`
Constructed with `new GameBitPacker<T>(schema, options?)`. `options` defaults to `DEFAULT_GAME_BITPACKER_OPTIONS` (every subsystem disabled). Every method inherited from `BitPacker` (`pack`, `unpack`, `registerSchema`, `registerStrategy`) works without modification.
Subsystem-specific methods include `pushState`, `getLatestState`, `applyPhysics`, `predictPosition`, `interpolate`, `reconcile`, and `quantizePosition` — see the corresponding subsystem `.md` files for detailed semantics.
## Implementation Notes
- **Composition over inheritance for subsystems.** Although `GameBitPacker extends BitPacker`, the four subsystems (`Predictor`, `Interpolator`, `Reconciliation`, `NetworkSimulator`) are composed members, not inherited classes. This keeps each subsystem testable in isolation and lets a consumer construct a `GameBitPacker` with only the subsystems they need.
- **`enable*` flags gate construction, not pack/unpack.** Disabled subsystems are not constructed at all (saves memory in lightweight server pipelines that just pack/unpack); their methods throw if called when the corresponding flag is `false`.
- **No global state.** Every internal manager is instance-scoped — multiple `GameBitPacker` instances coexist without cross-talk.
- **Sequence wrapping at 4 bits.** `GameStateManager.MAX_SEQUENCE = 15` enforces the 4-bit wire-format constraint inherited from upstream. Consumers serializing sequence numbers across the wire must respect this cap.

View File

@@ -0,0 +1,384 @@
import { BitPacker } from "@suaveplan/bitpacker";
import type { BitPackerSchema } from "@suaveplan/types";
import { GameIntegrationManager } from "../integration-manager/integration-manager.js";
import * as GameQuantization from "../quantization/quantization.js";
import { GameStateManager } from "../state-manager/state-manager.js";
import type {
GameState,
InputData,
NetworkStats,
PhysicsFunction,
} from "../types.js";
import {
DEFAULT_GAME_BITPACKER_OPTIONS,
type GameBitPackerOptions,
type IGameBitPacker,
type InterpolationMethod,
type NetworkSimulationOptions,
type ObjectWithKeys,
type RecursivePartial,
type Rotation,
type Vector2,
type Vector3,
} from "../types.js";
/**
* Enhanced GameBitPacker with prediction, interpolation, reconciliation, and network simulation
*/
export class GameBitPacker<T extends BitPackerSchema>
extends BitPacker<T>
implements IGameBitPacker<T>
{
private static readonly DELTA_FLAG = 0x80; // 1000 0000 - First bit indicates delta
private stateManager: GameStateManager<T>;
private integrationManager: GameIntegrationManager<T>;
private options: GameBitPackerOptions;
/**
* Create a new GameBitPacker
* @param schema BitPacker schema
* @param options GameBitPacker options
*/
constructor(schema: T, options: GameBitPackerOptions = {}) {
super(schema, {
delta: options.delta ?? true,
floatsAsStrings: options.floatsAsStrings ?? false,
});
this.options = { ...DEFAULT_GAME_BITPACKER_OPTIONS, ...options };
this.stateManager = new GameStateManager<T>();
this.integrationManager = new GameIntegrationManager<T>(
this.options,
schema
);
this.integrationManager.setBitPacker(this);
}
// Update the current state with new data
public update(data: unknown, isInitialState = false): void {
this.stateManager.update(data, isInitialState);
}
// Pack current state for network transmission
public packState(): Uint8Array | undefined {
const currentState = this.stateManager.getState();
if (!currentState) return undefined;
this.stateManager.updateLastSentState(currentState);
const data = this.pack(currentState);
const result = new Uint8Array(data.length + 1);
// Set header byte - no delta flag (0)
result[0] = 0;
result.set(data, 1);
return result;
}
// Pack delta update
public packDelta(): Uint8Array | undefined {
const currentState = this.stateManager.getState();
const lastSentState = this.stateManager.getLastSentState();
if (!currentState || !lastSentState) return undefined;
const delta = this.stateManager.computeDelta(
currentState,
lastSentState
);
if (!delta.hasChanges) return undefined;
const data = this.pack(currentState as unknown);
const result = new Uint8Array(data.length + 1);
// Set header with delta flag and sequence
result[0] =
GameBitPacker.DELTA_FLAG | (this.stateManager.getSequence() & 0x0f);
result.set(data, 1);
this.stateManager.updateLastSentState(currentState);
return result;
}
// Apply incoming update
public applyUpdate(data: Uint8Array): boolean {
if (data.length < 2) return false; // Need at least header + 1 byte
const header = data[0];
if (header === undefined) throw new Error("Header required");
const isDelta = (header & GameBitPacker.DELTA_FLAG) !== 0;
const incomingSequence = header & 0x0f;
// Handle sequence validation
const lastReceivedSequence =
this.stateManager.getLastReceivedSequence();
const expectedSequence =
(lastReceivedSequence + 1) % GameStateManager.MAX_SEQUENCE;
// Skip old updates - only validate sequence for delta packets
if (isDelta) {
// For delta packets, we need a valid previous state
if (!this.stateManager.getState()) {
return false;
}
if (
lastReceivedSequence !== -1 &&
incomingSequence !== expectedSequence
) {
return false;
}
}
try {
const updateData = data.slice(1);
const unpacked = this.unpack(updateData);
if (isDelta) {
// Apply delta to current state
const currentState = this.stateManager.getState();
if (!currentState) return false;
const updatedState = this.deepMerge(
this.deepClone(currentState) as ObjectWithKeys,
unpacked as RecursivePartial<ObjectWithKeys>
);
this.stateManager.update(updatedState);
} else {
// Replace entire state
this.stateManager.update(unpacked);
}
this.stateManager.updateLastReceivedSequence(incomingSequence);
return true;
} catch {
return false;
}
}
// Get current state
public getState(): unknown | undefined {
return this.stateManager.getState();
}
// Reset the game bit packer
public reset(): void {
this.stateManager.reset();
}
// Check if initialized
public isInitialized(): boolean {
return this.stateManager.isInitialized();
}
// Get current sequence number
public getSequence(): number {
return this.stateManager.getSequence();
}
// Get last received sequence number
public getLastReceivedSequence(): number {
return this.stateManager.getLastReceivedSequence();
}
// Quantize position
public quantizePosition(position: Vector3, precision = 0.01): Vector3 {
return GameQuantization.quantizePosition(position, precision);
}
// Quantize 2D position
public quantizePosition2D(position: Vector2, precision = 0.1): Vector2 {
return GameQuantization.quantizePosition2D(position, precision);
}
// Quantize rotation
public quantizeRotation(rotation: Rotation, precision = 0.1): Rotation {
return GameQuantization.quantizeRotation(rotation, precision);
}
// Interpolate position
public interpolatePosition(
from: Vector3,
to: Vector3,
t: number,
method: InterpolationMethod = "linear"
): Vector3 {
return this.integrationManager.interpolatePosition(from, to, t, method);
}
// Interpolate rotation
public interpolateRotation(
from: Rotation,
to: Rotation,
t: number,
method: InterpolationMethod = "linear"
): Rotation {
return this.integrationManager.interpolateRotation(from, to, t, method);
}
// Predict position
public predictPosition(deltaTime: number): Vector3 | null {
return this.integrationManager.predictPosition(deltaTime);
}
// Predict rotation
public predictRotation(deltaTime: number): Rotation | null {
return this.integrationManager.predictRotation(deltaTime);
}
// Add position to history
public addPositionToHistory(
position: Vector3,
timestamp = Date.now()
): void {
this.integrationManager.addPositionToHistory(position, timestamp);
}
// Add rotation to history
public addRotationToHistory(
rotation: Rotation,
timestamp = Date.now()
): void {
this.integrationManager.addRotationToHistory(rotation, timestamp);
}
// Process input
public processInput<I extends InputData>(
input: I,
timestamp = Date.now()
): number {
return this.integrationManager.processInput(input, timestamp);
}
// Apply client-side prediction
public predictClientState<S extends GameState, I extends InputData>(
currentState: S,
applyPhysics: PhysicsFunction<S, I>,
deltaTime: number
): S {
return this.integrationManager.predictClientState(
currentState,
applyPhysics,
deltaTime
);
}
// Process server update
public processServerUpdate<S extends GameState>(
serverState: S,
serverSequence: number,
serverTimestamp: number
): boolean {
return this.integrationManager.processServerUpdate(
serverState,
serverSequence,
serverTimestamp
);
}
// Reconcile state
public reconcileState<S extends GameState, I extends InputData>(
currentState: S,
applyPhysics: PhysicsFunction<S, I>
): S {
return this.integrationManager.reconcileState(
currentState,
applyPhysics
);
}
// Send packet with network simulation
public sendPacket(
data: Uint8Array,
callback: (data: Uint8Array) => void,
dropCallback?: () => void
): void {
this.integrationManager.sendPacket(data, callback, dropCallback);
}
// Set network options
public setNetworkOptions(options: NetworkSimulationOptions): void {
this.integrationManager.setNetworkOptions(options);
}
// Get network statistics
public getNetworkStats(): NetworkStats {
return this.integrationManager.getNetworkStats();
}
// Set features
public setFeatures(features: {
prediction?: boolean;
interpolation?: boolean;
reconciliation?: boolean;
networkSimulation?: boolean;
}): void {
this.integrationManager.setFeatures(features);
}
// Validate range
public validateRange(value: number, schemaRange: string | number): boolean {
return GameQuantization.validateRange(value, schemaRange);
}
// Deep clone helper
private deepClone<U>(obj: U): U {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as unknown as U;
if (Array.isArray(obj)) {
const clonedArr: unknown[] = [];
for (const item of obj) {
clonedArr.push(this.deepClone(item));
}
return clonedArr as unknown as U;
}
if (obj instanceof Object) {
const clonedObj: Record<string, unknown> = {};
for (const key in obj) {
if (Object.hasOwn(obj, key)) {
clonedObj[key] = this.deepClone(obj[key]);
}
}
return clonedObj as U;
}
return obj;
}
// Deep merge helper
private deepMerge<U extends ObjectWithKeys>(
target: U,
source: RecursivePartial<U>
): U {
for (const key of Object.keys(source as ObjectWithKeys)) {
const sourceValue = (source as ObjectWithKeys)[key];
const targetValue = target[key];
if (sourceValue === undefined) {
continue;
}
if (
sourceValue &&
typeof sourceValue === "object" &&
!Array.isArray(sourceValue) &&
targetValue &&
typeof targetValue === "object" &&
!Array.isArray(targetValue)
) {
(target as Record<string, unknown>)[key] = this.deepMerge(
targetValue as ObjectWithKeys,
sourceValue as RecursivePartial<ObjectWithKeys>
);
} else {
(target as Record<string, unknown>)[key] = sourceValue;
}
}
return target;
}
// Clean up resources
public destroy(): void {
this.integrationManager.destroy();
}
}

View File

@@ -0,0 +1,48 @@
/**
* Re-export game-related types from main types file
*/
export type {
GameBitPackerOptions,
GameFeatures,
GameState,
HistoryPoint,
IGameBitPacker,
InputBufferEntry,
InputData,
InterpolationMethod,
InterpolationOptions,
IStateReconciliation,
KalmanState,
NetworkSimulationOptions,
NetworkSimulationStats,
NetworkStats,
ObjectWithKeys,
PacketInfo,
PhysicsFunction,
PlayerInput,
PlayerSchema,
PlayerState,
PlayerStats,
PredictionOptions,
ReconciliationOptions,
RecursivePartial,
Rotation,
StateBufferEntry,
StateDelta,
ValidationResult,
Vector2,
Vector3,
} from "../types.js";
export {
DEFAULT_GAME_BITPACKER_OPTIONS,
DEFAULT_INTERPOLATION_OPTIONS,
DEFAULT_NETWORK_SIMULATION_OPTIONS,
DEFAULT_PREDICTION_OPTIONS,
DEFAULT_RECONCILIATION_OPTIONS,
InterpolationMethodEnum,
NETWORK_CONDITIONS,
NetworkCondition,
PacketStatus,
PredictionAlgorithm,
} from "../types.js";

View File

@@ -0,0 +1,184 @@
import { describe, expect, test } from "@suaveplan/testing/runner";
import {
calculateVelocity,
dequantizeVector,
GameInterpolator,
GameStateManager,
PositionPredictor,
PredictionAlgorithm,
quantizeVector,
RotationPredictor,
StateReconciliation,
} from "./index.js";
describe("GameStateManager", () => {
test("pushes and retrieves states", () => {
const manager = new GameStateManager();
manager.pushState({ version: 1, entities: {} });
expect(manager.getStateCount()).toBe(1);
expect(manager.getLatestState()?.version).toBe(1);
expect(manager.getStateAt(0)?.version).toBe(1);
});
test("getStateAt returns undefined for out of bounds", () => {
const manager = new GameStateManager();
expect(manager.getStateAt(0)).toBeUndefined();
});
test("getLatestState returns undefined when empty", () => {
const manager = new GameStateManager();
expect(manager.getLatestState()).toBeUndefined();
});
});
describe("quantize/dequantize", () => {
test("roundtrips a vector", () => {
const v = { x: 0.5, y: -0.25, z: 0.75 };
const q = quantizeVector(v, 16);
const d = dequantizeVector(q, 16);
expect(d.x).toBeCloseTo(v.x, 1);
expect(d.y).toBeCloseTo(v.y, 1);
expect(d.z).toBeCloseTo(v.z, 1);
});
test("handles vector without z component", () => {
const v = { x: 0.5, y: -0.25 };
const q = quantizeVector(v, 8);
expect(q.z).toBeUndefined();
const d = dequantizeVector(q, 8);
expect(d.x).toBeCloseTo(v.x, 1);
expect(d.y).toBeCloseTo(v.y, 1);
expect(d.z).toBeUndefined();
});
});
describe("PositionPredictor", () => {
test("needs at least 2 points to predict", () => {
const predictor = new PositionPredictor();
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
expect(predictor.predictPosition(100)).toBeNull();
});
test("linear prediction works", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.LINEAR,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
const pred = predictor.predictPosition(100);
expect(pred).not.toBeNull();
expect(pred!.x).toBeGreaterThan(10);
});
test("clear history resets predictor", () => {
const predictor = new PositionPredictor();
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
predictor.clearHistory();
expect(predictor.predictPosition(100)).toBeNull();
});
});
describe("RotationPredictor", () => {
test("predicts rotation", () => {
const predictor = new RotationPredictor();
predictor.addRotation({ yaw: 0, pitch: 0 }, 0);
predictor.addRotation({ yaw: 10, pitch: 5 }, 1000);
const pred = predictor.predictRotation(100);
expect(pred).not.toBeNull();
expect(pred!.yaw).toBeGreaterThan(0);
});
});
describe("GameInterpolator", () => {
test("linear position interpolation", () => {
const interp = new GameInterpolator();
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 20, z: 30 },
0.5
);
expect(result.x).toBe(5);
expect(result.y).toBe(10);
expect(result.z).toBe(15);
});
test("rotation interpolation handles angle wrapping", () => {
const interp = new GameInterpolator();
const result = interp.interpolateRotation(
{ yaw: 170, pitch: 0 },
{ yaw: -170, pitch: 0 },
0.5
);
expect(Math.abs(result.yaw)).toBeLessThanOrEqual(180);
});
test("player state interpolation", () => {
const interp = new GameInterpolator();
const start = {
position: { x: 0, y: 0, z: 0 },
rotation: { yaw: 0, pitch: 0 },
stats: {
health: 100,
shield: 50,
speed: 5,
isMoving: false,
isSprinting: false,
},
};
const end = {
position: { x: 10, y: 0, z: 0 },
rotation: { yaw: 90, pitch: 0 },
stats: {
health: 80,
shield: 40,
speed: 10,
isMoving: true,
isSprinting: true,
},
};
const result = interp.interpolatePlayerState(start, end, 0.5);
expect(result.position.x).toBe(5);
expect(result.stats.health).toBe(90);
});
});
describe("StateReconciliation", () => {
test("processes inputs", () => {
const sr = new StateReconciliation();
const seq = sr.processInput(
{ moveX: 1, moveY: 0, rotateYaw: 0, rotatePitch: 0, actions: [] },
0
);
expect(seq).toBe(0);
expect(sr.getClientSequence()).toBe(0);
});
test("ping/pong tracking", () => {
const sr = new StateReconciliation();
sr.sendPing();
sr.receivePong();
expect(sr.getAveragePing()).toBeGreaterThanOrEqual(0);
});
test("reset clears state", () => {
const sr = new StateReconciliation();
sr.processInput(
{ moveX: 1, moveY: 0, rotateYaw: 0, rotatePitch: 0, actions: [] },
0
);
sr.reset();
expect(sr.getClientSequence()).toBe(-1);
});
});
describe("calculateVelocity", () => {
test("calculates velocity from history", () => {
const history = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 10, y: 0, z: 0 }, timestamp: 1000 },
];
const vel = calculateVelocity(history);
expect(vel.x).toBe(10);
expect(vel.y).toBe(0);
});
test("returns zero for single point", () => {
const history = [{ value: { x: 5, y: 0, z: 0 }, timestamp: 0 }];
const vel = calculateVelocity(history);
expect(vel.x).toBe(0);
});
});

View File

@@ -0,0 +1,8 @@
## index
**Path:** `bitpacker-game/src/index.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,105 @@
/**
* Game state schemas and utilities for bitpacker.
*/
export { GameInterpolator } from "./interpolation/index.js";
export {
calculateAcceleration,
calculateAccelerationAt,
calculateJerk,
calculateVelocity,
calculateVelocityAt,
catmullRomPredict,
cubicPredict,
hermitePredict,
initKalmanState,
kalmanPredict,
linearPredict,
PositionPredictor,
quadraticPredict,
RotationPredictor,
updateKalmanFilter,
verletPredict,
} from "./prediction/index.js";
export { StateReconciliation } from "./reconciliation/index.js";
export type {
HistoryPoint,
InputBufferEntry,
InterpolationMethod,
InterpolationOptions,
KalmanState,
PlayerInput,
PlayerState,
PlayerStats,
PredictionOptions,
ReconciliationOptions,
Rotation,
StateBufferEntry,
Vector3,
} from "./types.js";
export {
DEFAULT_INTERPOLATION_OPTIONS,
DEFAULT_PREDICTION_OPTIONS,
DEFAULT_RECONCILIATION_OPTIONS,
PredictionAlgorithm,
} from "./types.js";
export interface GameStateSchema {
readonly version: number;
readonly entities: Record<string, unknown>;
}
export interface QuantizedVector {
readonly x: number;
readonly y: number;
readonly z?: number | undefined;
}
export function quantizeVector(
vector: QuantizedVector,
bitsPerComponent: number
): QuantizedVector {
const maxVal = (1 << bitsPerComponent) - 1;
const scale = maxVal / 2;
return {
x: Math.round((vector.x + 1) * scale),
y: Math.round((vector.y + 1) * scale),
z:
vector.z !== undefined
? Math.round((vector.z + 1) * scale)
: undefined,
};
}
export function dequantizeVector(
vector: QuantizedVector,
bitsPerComponent: number
): QuantizedVector {
const maxVal = (1 << bitsPerComponent) - 1;
const scale = maxVal / 2;
return {
x: vector.x / scale - 1,
y: vector.y / scale - 1,
z: vector.z !== undefined ? vector.z / scale - 1 : undefined,
};
}
export class GameStateManager {
private states: GameStateSchema[] = [];
pushState(state: GameStateSchema): void {
this.states.push(state);
}
getLatestState(): GameStateSchema | undefined {
return this.states.at(-1);
}
getStateAt(index: number): GameStateSchema | undefined {
return this.states[index];
}
getStateCount(): number {
return this.states.length;
}
}

View File

@@ -0,0 +1,77 @@
# GameIntegrationManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Cross-subsystem orchestration for the `GameBitPacker`. `GameIntegrationManager<T>` owns the four optional subsystems (`PositionPredictor`, `RotationPredictor`, `GameInterpolator`, `StateReconciliation`, `NetworkSimulator`) and routes calls between them so the `GameBitPacker` facade exposes a single high-level API instead of a five-way junction. Constructing a manager with `enable*: false` flags omits the corresponding subsystem entirely — the manager allocates only what's enabled.
The integration manager is the right home for cross-subsystem invariants: e.g. when a reconciliation rollback fires, the predictor's history needs to be rewound by the same number of frames, and the interpolator's smoothing buffer needs to be flushed. Centralizing those rules here keeps each subsystem free of cross-knowledge.
## Features
- Optional construction of `PositionPredictor`, `RotationPredictor`, `GameInterpolator`, `StateReconciliation`, `NetworkSimulator`
- Routes `applyPhysics`, `predictPosition`, `interpolate`, `reconcile`, `simulateSend`, `simulateReceive` to the corresponding subsystem
- Flushes / rewinds dependent subsystems atomically on reconciliation rollback
- Exposes `getNetworkStats(): NetworkSimulationStats` to surface aggregate counters
- All subsystems are accessible via their typed getters for advanced use
## Usage: Basic Example
```ts
import { GameIntegrationManager, PLAYER_STATE_SCHEMA } from "@suaveplan/bitpacker-game";
const mgr = new GameIntegrationManager(PLAYER_STATE_SCHEMA, {
enablePrediction: true,
enableInterpolation: true,
});
const predicted = mgr.predictPosition(currentPos, /*dt*/ 16);
const smooth = mgr.interpolate(prev, next, 0.5);
```
## Usage: Advanced Example
```ts
import {
GameIntegrationManager,
PLAYER_STATE_SCHEMA,
PredictionAlgorithm,
PacketStatus,
} from "@suaveplan/bitpacker-game";
const mgr = new GameIntegrationManager(PLAYER_STATE_SCHEMA, {
enablePrediction: true,
enableReconciliation: true,
enableNetworkSimulation: true,
predictionOptions: { algorithm: PredictionAlgorithm.KALMAN, historySize: 8 },
networkSimulationOptions: { latencyMin: 50, latencyMax: 100, packetLoss: 2 },
});
const send = mgr.simulateSend(payload);
if (send.status === PacketStatus.DELIVERED) {
/* …route through pipeline… */
}
const stats = mgr.getNetworkStats();
console.log(stats.packetLossRate, stats.averageLatency);
```
## API Reference
### `class GameIntegrationManager<T extends BitPackerSchema>`
Constructor takes the schema and a `GameBitPackerOptions` bundle. Subsystems are allocated based on the `enable*` flags.
#### Routing methods
- `predictPosition(current, dt)` / `predictRotation(current, dt)` — delegate to the corresponding predictor; throw if prediction is disabled
- `interpolate(a, b, t)` — delegate to the interpolator
- `reconcile(serverState, clientHistory)` — delegate to the reconciliation manager and atomically flush dependent subsystems
- `simulateSend(payload)` / `simulateReceive(payload)` — delegate to the network simulator and emit OTel events on the parent span
- `getNetworkStats()` — return the simulator's aggregate counters
## Implementation Notes
- **Atomic rollback semantics.** When `reconcile` performs a rollback, the predictor's history is truncated to the rollback frame and the interpolator's smoothing buffer is flushed in the same call. Splitting these into separate calls would create a window where the predictor and interpolator disagree on the canonical timeline.
- **Disabled subsystems throw, don't no-op.** Calling `predictPosition` with `enablePrediction: false` throws rather than returning a stub value. Silently no-op'ing would mask configuration bugs at the call site.
- **NetworkSimulator events.** `simulateSend` / `simulateReceive` are the only routing methods that produce per-packet OTel events on the parent `bitpacker_game.send` / `bitpacker_game.receive` spans. The other subsystems are too high-frequency to instrument per-call.

View File

@@ -0,0 +1,392 @@
/**
* Integration manager for GameBitPacker features
*/
import {
type NetworkSimulationOptions,
type NetworkSimulationStats,
NetworkSimulator,
PacketStatus,
} from "@suaveplan/bitpacker-network";
import type { BitPackerSchema } from "@suaveplan/types";
import { GameInterpolator } from "../interpolation/interpolation.js";
import {
PositionPredictor,
RotationPredictor,
} from "../prediction/prediction.js";
import * as GameQuantization from "../quantization/quantization.js";
import { StateReconciliation } from "../reconciliation/reconciliation.js";
import type {
GameBitPackerOptions,
GameState,
IGameBitPacker,
InputData,
InterpolationMethod,
InterpolationMethodEnum,
NetworkStats,
PhysicsFunction,
PlayerInput,
PlayerState,
Rotation,
Vector3,
} from "../types.js";
import type {
GameIntegrationManagerInterface,
IntegrationManagerFeatures,
} from "./integration-manager.types.js";
export class GameIntegrationManager<T extends BitPackerSchema>
implements GameIntegrationManagerInterface<T>
{
private positionPredictor: PositionPredictor | null = null;
private rotationPredictor: RotationPredictor | null = null;
private interpolator: GameInterpolator | null = null;
private reconciliation: StateReconciliation<T> | null = null;
private networkSimulator: NetworkSimulator | null = null;
constructor(
private options: GameBitPackerOptions,
private schema: T
) {
this.initializeFeatures();
}
/**
* Initialize enhanced features based on options
*/
private initializeFeatures(): void {
if (this.options.enablePrediction) {
this.positionPredictor = new PositionPredictor(
this.options.predictionOptions
);
this.rotationPredictor = new RotationPredictor(
this.options.predictionOptions
);
}
if (this.options.enableInterpolation) {
this.interpolator = new GameInterpolator(
this.options.interpolationOptions
);
}
if (this.options.enableReconciliation) {
this.reconciliation = new StateReconciliation<T>(
this.schema,
this.options.reconciliationOptions
);
}
if (this.options.enableNetworkSimulation) {
this.networkSimulator = new NetworkSimulator(
this.options.networkSimulationOptions
);
}
}
/**
* Set BitPacker reference for reconciliation
*/
public setBitPacker(bitPacker: IGameBitPacker<T>): void {
if (this.reconciliation) {
this.reconciliation.setBitPacker(bitPacker);
}
}
/**
* Interpolate position
*/
public interpolatePosition(
from: Vector3,
to: Vector3,
t: number,
method?: InterpolationMethod | InterpolationMethodEnum
): Vector3 {
if (!this.interpolator) {
// Simple linear interpolation fallback
return {
x: from.x + (to.x - from.x) * t,
y: from.y + (to.y - from.y) * t,
z: from.z + (to.z - from.z) * t,
};
}
return this.interpolator.interpolatePosition(from, to, t, method);
}
/**
* Interpolate rotation
*/
public interpolateRotation(
from: Rotation,
to: Rotation,
t: number,
method?: InterpolationMethod | InterpolationMethodEnum
): Rotation {
if (!this.interpolator) {
// Simple linear interpolation fallback with angle wrapping
if (
"yaw" in from &&
"pitch" in from &&
"yaw" in to &&
"pitch" in to
) {
return {
yaw: GameQuantization.interpolateAngle(from.yaw, to.yaw, t),
pitch: GameQuantization.interpolateAngle(
from.pitch,
to.pitch,
t
),
};
}
return to;
}
return this.interpolator.interpolateRotation(from, to, t, method);
}
/**
* Predict position
*/
public predictPosition(deltaTime: number): Vector3 | null {
if (!this.positionPredictor) {
return null;
}
return this.positionPredictor.predictPosition(deltaTime);
}
/**
* Predict rotation
*/
public predictRotation(deltaTime: number): Rotation | null {
if (!this.rotationPredictor) {
return null;
}
return this.rotationPredictor.predictRotation(deltaTime);
}
/**
* Add position to history
*/
public addPositionToHistory(
position: Vector3,
timestamp = Date.now()
): void {
if (this.positionPredictor) {
this.positionPredictor.addPosition(position, timestamp);
}
}
/**
* Add rotation to history
*/
public addRotationToHistory(
rotation: Rotation,
timestamp = Date.now()
): void {
if (this.rotationPredictor) {
this.rotationPredictor.addRotation(rotation, timestamp);
}
}
/**
* Process input
*/
public processInput<I extends InputData>(
input: I,
timestamp = Date.now()
): number {
if (!this.reconciliation) {
return -1;
}
return this.reconciliation.processInput(
input as unknown as PlayerInput,
timestamp
);
}
/**
* Apply client-side prediction
*/
public predictClientState<S extends GameState, I extends InputData>(
currentState: S,
applyPhysics: PhysicsFunction<S, I>,
deltaTime: number
): S {
if (!this.reconciliation) {
return currentState;
}
return this.reconciliation.predictClientState(
currentState as unknown as PlayerState,
applyPhysics as unknown as PhysicsFunction<
PlayerState,
PlayerInput
>,
deltaTime
) as unknown as S;
}
/**
* Process server update
*/
public processServerUpdate<S extends GameState>(
serverState: S,
serverSequence: number,
serverTimestamp: number
): boolean {
if (!this.reconciliation) {
return false;
}
return this.reconciliation.processServerUpdate(
serverState as unknown as PlayerState,
serverSequence,
serverTimestamp
);
}
/**
* Reconcile state
*/
public reconcileState<S extends GameState, I extends InputData>(
currentState: S,
applyPhysics: PhysicsFunction<S, I>
): S {
if (!this.reconciliation) {
return currentState;
}
return this.reconciliation.reconcileState(
currentState as unknown as PlayerState,
applyPhysics as unknown as PhysicsFunction<PlayerState, PlayerInput>
) as unknown as S;
}
/**
* Send packet with network simulation
*/
public sendPacket(
data: Uint8Array,
callback: (data: Uint8Array) => void,
dropCallback?: () => void
): void {
if (!this.networkSimulator) {
callback(data);
return;
}
this.networkSimulator.sendPacket(data, (status, resultData) => {
if (status === PacketStatus.LOST && dropCallback) {
dropCallback();
} else if (resultData) {
callback(resultData);
}
});
}
/**
* Set network options
*/
public setNetworkOptions(options: NetworkSimulationOptions): void {
if (this.networkSimulator) {
this.networkSimulator.setOptions(options);
}
}
/**
* Get network statistics
*/
public getNetworkStats(): NetworkStats {
if (!this.networkSimulator) {
return {
packetsSent: 0,
packetsReceived: 0,
packetsLost: 0,
packetsDelayed: 0,
packetsReordered: 0,
averageLatency: 0,
averageJitter: 0,
totalBytes: 0,
};
}
const stats =
this.networkSimulator.getStats() as NetworkSimulationStats;
return {
packetsSent: stats.packetsSent,
packetsReceived: stats.packetsReceived,
packetsLost: stats.packetsLost,
packetsDelayed: 0, // Not in NetworkSimulationStats
packetsReordered: stats.packetsReordered,
averageLatency: stats.averageLatency,
averageJitter: 0, // Not in NetworkSimulationStats
totalBytes: stats.bytesTransferred,
};
}
/**
* Set features dynamically
*/
public setFeatures(features: IntegrationManagerFeatures): void {
// Update prediction
if (features.prediction !== undefined) {
this.options.enablePrediction = features.prediction;
if (features.prediction && !this.positionPredictor) {
this.positionPredictor = new PositionPredictor(
this.options.predictionOptions
);
this.rotationPredictor = new RotationPredictor(
this.options.predictionOptions
);
} else if (!features.prediction) {
this.positionPredictor = null;
this.rotationPredictor = null;
}
}
// Update interpolation
if (features.interpolation !== undefined) {
this.options.enableInterpolation = features.interpolation;
if (features.interpolation && !this.interpolator) {
this.interpolator = new GameInterpolator(
this.options.interpolationOptions
);
} else if (!features.interpolation) {
this.interpolator = null;
}
}
// Update reconciliation
if (features.reconciliation !== undefined) {
this.options.enableReconciliation = features.reconciliation;
if (features.reconciliation && !this.reconciliation) {
this.reconciliation = new StateReconciliation<T>(
this.schema,
this.options.reconciliationOptions
);
} else if (!features.reconciliation) {
this.reconciliation = null;
}
}
// Update network simulation
if (features.networkSimulation !== undefined) {
this.options.enableNetworkSimulation = features.networkSimulation;
if (features.networkSimulation && !this.networkSimulator) {
this.networkSimulator = new NetworkSimulator(
this.options.networkSimulationOptions
);
} else if (!features.networkSimulation) {
if (this.networkSimulator) {
this.networkSimulator.destroy();
}
this.networkSimulator = null;
}
}
}
/**
* Clean up resources
*/
public destroy(): void {
if (this.networkSimulator) {
this.networkSimulator.destroy();
this.networkSimulator = null;
}
}
}

View File

@@ -0,0 +1,68 @@
/**
* Types for Game Integration Manager
*/
import type { BitPackerSchema } from "@suaveplan/types";
import type {
GameState,
IGameBitPacker,
InputData,
InterpolationMethod,
InterpolationMethodEnum,
NetworkSimulationOptions,
NetworkStats,
PhysicsFunction,
Rotation,
Vector3,
} from "../types.js";
export interface IntegrationManagerFeatures {
prediction?: boolean;
interpolation?: boolean;
reconciliation?: boolean;
networkSimulation?: boolean;
}
export interface GameIntegrationManagerInterface<T extends BitPackerSchema> {
setBitPacker(bitPacker: IGameBitPacker<T>): void;
interpolatePosition(
from: Vector3,
to: Vector3,
t: number,
method?: InterpolationMethod | InterpolationMethodEnum
): Vector3;
interpolateRotation(
from: Rotation,
to: Rotation,
t: number,
method?: InterpolationMethod | InterpolationMethodEnum
): Rotation;
predictPosition(deltaTime: number): Vector3 | null;
predictRotation(deltaTime: number): Rotation | null;
addPositionToHistory(position: Vector3, timestamp?: number): void;
addRotationToHistory(rotation: Rotation, timestamp?: number): void;
processInput<I extends InputData>(input: I, timestamp?: number): number;
predictClientState<S extends GameState, I extends InputData>(
currentState: S,
applyPhysics: PhysicsFunction<S, I>,
deltaTime: number
): S;
processServerUpdate<S extends GameState>(
serverState: S,
serverSequence: number,
serverTimestamp: number
): boolean;
reconcileState<S extends GameState, I extends InputData>(
currentState: S,
applyPhysics: PhysicsFunction<S, I>
): S;
sendPacket(
data: Uint8Array,
callback: (data: Uint8Array) => void,
dropCallback?: () => void
): void;
setNetworkOptions(options: NetworkSimulationOptions): void;
getNetworkStats(): NetworkStats;
setFeatures(features: IntegrationManagerFeatures): void;
destroy(): void;
}

View File

@@ -0,0 +1,39 @@
## Interpolation
**Path:** `src/interpolation/interpolation.ts`
**Export:** `GameInterpolator`
### What it does
Advanced interpolation of game state between two points in time. Supports 9 interpolation methods for positions and rotations.
### Methods
| Method | Description |
|--------|-------------|
| `LINEAR` | Simple lerp |
| `CUBIC` | Cubic easing |
| `HERMITE` | Hermite spline with tension/bias |
| `CATMULL_ROM` | Catmull-Rom spline |
| `BEZIER` | Cubic Bezier |
| `SPRING` | Spring physics |
| `ELASTIC` | Elastic easing |
| `BOUNCE` | Bounce easing |
| `SMOOTHSTEP` | 3t² - 2t³ smoothstep |
### Key API
| Method | Description |
|--------|-------------|
| `interpolatePosition(start, end, t, method?)` | Interpolate `Vector3` |
| `interpolateRotation(start, end, t, method?)` | Interpolate `Rotation` with angle wrapping |
| `interpolatePlayerState(start, end, t)` | Full player state interpolation |
| `interpolatePath(points, t, method?)` | Interpolate along a path of points |
### Rotation Handling
Yaw is normalized to `[-180, 180]` and takes the shortest path. Pitch is clamped to `[-90, 90]`.
### Depends on
- `../types.js` — `Vector3`, `Rotation`, `PlayerState`, `InterpolationOptions`

View File

@@ -0,0 +1 @@
export { GameInterpolator } from "./interpolation.js";

View File

@@ -0,0 +1,12 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## interpolation
**Path:** `bitpacker-game/src/interpolation/interpolation.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,447 @@
import {
DEFAULT_INTERPOLATION_OPTIONS,
type InterpolationMethod,
InterpolationMethodEnum,
type InterpolationOptions,
type PlayerState,
type Rotation,
type Vector3,
} from "../types.js";
export class GameInterpolator {
private options: InterpolationOptions;
constructor(options: InterpolationOptions = {}) {
this.options = { ...DEFAULT_INTERPOLATION_OPTIONS, ...options };
}
interpolatePosition(
start: Vector3,
end: Vector3,
t: number,
method?: InterpolationMethod | InterpolationMethodEnum
): Vector3 {
const m =
method || this.options.method || InterpolationMethodEnum.LINEAR;
switch (m) {
case InterpolationMethodEnum.LINEAR:
return this.linearInterpolate(start, end, t);
case InterpolationMethodEnum.CUBIC:
return this.cubicInterpolate(start, end, t);
case InterpolationMethodEnum.HERMITE:
return this.hermiteInterpolate(start, end, t);
case InterpolationMethodEnum.CATMULL_ROM:
return this.catmullRomInterpolate(start, end, t);
case InterpolationMethodEnum.BEZIER:
return this.bezierInterpolate(start, end, t);
case InterpolationMethodEnum.SPRING:
return this.springInterpolate(start, end, t);
case InterpolationMethodEnum.ELASTIC:
return this.elasticInterpolate(start, end, t);
case InterpolationMethodEnum.BOUNCE:
return this.bounceInterpolate(start, end, t);
case InterpolationMethodEnum.SMOOTHSTEP:
return this.smoothstepInterpolate(start, end, t);
default:
return this.linearInterpolate(start, end, t);
}
}
interpolateRotation(
start: Rotation,
end: Rotation,
t: number,
method?: InterpolationMethod | InterpolationMethodEnum
): Rotation {
const m =
method || this.options.method || InterpolationMethodEnum.LINEAR;
const ns = {
yaw: this.normalizeAngle(start.yaw),
pitch: this.clampPitch(start.pitch),
};
const ne = {
yaw: this.normalizeAngle(end.yaw),
pitch: this.clampPitch(end.pitch),
};
if (Math.abs(ne.yaw - ns.yaw) > 180) {
if (ns.yaw < ne.yaw) ns.yaw += 360;
else ne.yaw += 360;
}
let result: Rotation;
switch (m) {
case InterpolationMethodEnum.CUBIC:
result = {
yaw: this.cubicInterpolateScalar(ns.yaw, ne.yaw, t),
pitch: this.cubicInterpolateScalar(ns.pitch, ne.pitch, t),
};
break;
case InterpolationMethodEnum.SMOOTHSTEP: {
const st = this.smoothstep(0, 1, t);
result = {
yaw: ns.yaw + (ne.yaw - ns.yaw) * st,
pitch: ns.pitch + (ne.pitch - ns.pitch) * st,
};
break;
}
default:
result = {
yaw: ns.yaw + (ne.yaw - ns.yaw) * t,
pitch: ns.pitch + (ne.pitch - ns.pitch) * t,
};
}
return {
yaw: this.normalizeAngle(result.yaw),
pitch: this.clampPitch(result.pitch),
};
}
interpolatePlayerState(
start: PlayerState,
end: PlayerState,
t: number
): PlayerState {
const position = this.interpolatePosition(
start.position,
end.position,
t
);
const rotation = this.interpolateRotation(
start.rotation,
end.rotation,
t
);
const health =
start.stats.health + (end.stats.health - start.stats.health) * t;
const shield =
start.stats.shield + (end.stats.shield - start.stats.shield) * t;
const speed =
Number(start.stats.speed) +
(Number(end.stats.speed) - Number(start.stats.speed)) * t;
const isMoving = t > 0.5 ? end.stats.isMoving : start.stats.isMoving;
const isSprinting =
t > 0.5 ? end.stats.isSprinting : start.stats.isSprinting;
let optional: PlayerState["optional"] | undefined;
if (start.optional || end.optional) {
optional = {};
if (end.optional?.animation)
optional.animation = end.optional.animation;
else if (start.optional?.animation)
optional.animation = start.optional.animation;
if (start.optional?.effects || end.optional?.effects) {
const startEffects = start.optional?.effects || [];
const endEffects = end.optional?.effects || [];
optional.effects = Array.from(
new Set([...startEffects, ...endEffects])
);
}
}
return {
position,
rotation,
stats: { health, shield, speed, isMoving, isSprinting },
optional,
};
}
interpolatePath(
points: Vector3[],
t: number,
method?: InterpolationMethod | InterpolationMethodEnum
): Vector3 {
if (points.length === 0) return { x: 0, y: 0, z: 0 };
if (points.length === 1)
return {
x: points[0]?.x ?? 0,
y: points[0]?.y ?? 0,
z: points[0]?.z ?? 0,
};
if (points.length === 2)
return this.interpolatePosition(points[0]!, points[1]!, t, method);
const segments = points.length - 1;
const segmentT = t * segments;
const segmentIndex = Math.min(Math.floor(segmentT), segments - 1);
const localT = segmentT - segmentIndex;
const s0 = points[segmentIndex]!;
const s1 = points[segmentIndex + 1]!;
return this.interpolatePosition(s0, s1, localT, method);
}
private linearInterpolate(
start: Vector3,
end: Vector3,
t: number
): Vector3 {
return {
x: start.x + (end.x - start.x) * t,
y: start.y + (end.y - start.y) * t,
z: start.z + (end.z - start.z) * t,
};
}
private cubicInterpolate(start: Vector3, end: Vector3, t: number): Vector3 {
const ts = t * t;
const tc = ts * t;
const f = 3 * ts - 2 * tc;
return {
x: start.x + (end.x - start.x) * f,
y: start.y + (end.y - start.y) * f,
z: start.z + (end.z - start.z) * f,
};
}
private cubicInterpolateScalar(
start: number,
end: number,
t: number
): number {
const ts = t * t;
const tc = ts * t;
return start + (end - start) * (3 * ts - 2 * tc);
}
private hermiteInterpolate(
start: Vector3,
end: Vector3,
t: number
): Vector3 {
const tension =
this.options.tension || DEFAULT_INTERPOLATION_OPTIONS.tension;
const bias = this.options.bias || DEFAULT_INTERPOLATION_OPTIONS.bias;
const ts = {
x: (end.x - start.x) * tension * (1 + bias),
y: (end.y - start.y) * tension * (1 + bias),
z: (end.z - start.z) * tension * (1 + bias),
};
const te = {
x: (end.x - start.x) * tension * (1 - bias),
y: (end.y - start.y) * tension * (1 - bias),
z: (end.z - start.z) * tension * (1 - bias),
};
const t2 = t * t;
const t3 = t2 * t;
const h1 = 2 * t3 - 3 * t2 + 1;
const h2 = -2 * t3 + 3 * t2;
const h3 = t3 - 2 * t2 + t;
const h4 = t3 - t2;
return {
x: h1 * start.x + h2 * end.x + h3 * ts.x + h4 * te.x,
y: h1 * start.y + h2 * end.y + h3 * ts.y + h4 * te.y,
z: h1 * start.z + h2 * end.z + h3 * ts.z + h4 * te.z,
};
}
private catmullRomInterpolate(
start: Vector3,
end: Vector3,
t: number
): Vector3 {
const p0 = {
x: start.x - (end.x - start.x),
y: start.y - (end.y - start.y),
z: start.z - (end.z - start.z),
};
const p3 = {
x: end.x + (end.x - start.x),
y: end.y + (end.y - start.y),
z: end.z + (end.z - start.z),
};
return this.catmullRomInterpolatePoints(p0, start, end, p3, t);
}
private catmullRomInterpolatePoints(
p0: Vector3,
p1: Vector3,
p2: Vector3,
p3: Vector3,
t: number
): Vector3 {
const tension =
this.options.tension || DEFAULT_INTERPOLATION_OPTIONS.tension;
const t2 = t * t;
const t3 = t2 * t;
const s = (1 - tension) / 2;
const b0 =
s *
((-t3 + 2 * t2 - t) * p0.x +
(3 * t3 - 5 * t2 + 2) * p1.x +
(-3 * t3 + 4 * t2 + t) * p2.x +
(t3 - t2) * p3.x);
const b1 =
s *
((-t3 + 2 * t2 - t) * p0.y +
(3 * t3 - 5 * t2 + 2) * p1.y +
(-3 * t3 + 4 * t2 + t) * p2.y +
(t3 - t2) * p3.y);
const b2 =
s *
((-t3 + 2 * t2 - t) * p0.z +
(3 * t3 - 5 * t2 + 2) * p1.z +
(-3 * t3 + 4 * t2 + t) * p2.z +
(t3 - t2) * p3.z);
return { x: 2 * b0, y: 2 * b1, z: 2 * b2 };
}
private bezierInterpolate(
start: Vector3,
end: Vector3,
t: number
): Vector3 {
const c1 = {
x: start.x + (end.x - start.x) / 3,
y: start.y + (end.y - start.y) / 3,
z: start.z + (end.z - start.z) / 3,
};
const c2 = {
x: start.x + (2 * (end.x - start.x)) / 3,
y: start.y + (2 * (end.y - start.y)) / 3,
z: start.z + (2 * (end.z - start.z)) / 3,
};
return this.bezierInterpolatePoints(start, c1, c2, end, t);
}
private bezierInterpolatePoints(
p0: Vector3,
p1: Vector3,
p2: Vector3,
p3: Vector3,
t: number
): Vector3 {
const omt = 1 - t;
const omt2 = omt * omt;
const omt3 = omt2 * omt;
const t2 = t * t;
const t3 = t2 * t;
return {
x:
omt3 * p0.x +
3 * omt2 * t * p1.x +
3 * omt * t2 * p2.x +
t3 * p3.x,
y:
omt3 * p0.y +
3 * omt2 * t * p1.y +
3 * omt * t2 * p2.y +
t3 * p3.y,
z:
omt3 * p0.z +
3 * omt2 * t * p1.z +
3 * omt * t2 * p2.z +
t3 * p3.z,
};
}
private springInterpolate(
start: Vector3,
end: Vector3,
t: number
): Vector3 {
const k =
this.options.springConstant ||
DEFAULT_INTERPOLATION_OPTIONS.springConstant;
const d = this.options.damping || DEFAULT_INTERPOLATION_OPTIONS.damping;
const omega = Math.sqrt(k);
const zeta = d;
let dampedOmega: number;
let amplitude: number;
let phase: number;
if (zeta < 1) {
dampedOmega = omega * Math.sqrt(1 - zeta * zeta);
amplitude = Math.exp(-zeta * omega * t);
phase = Math.cos(dampedOmega * t);
} else if (zeta === 1) {
amplitude = Math.exp(-omega * t);
phase = 1 + omega * t;
} else {
const alpha = omega * (zeta + Math.sqrt(zeta * zeta - 1));
const beta = omega * (zeta - Math.sqrt(zeta * zeta - 1));
amplitude =
(Math.exp(-alpha * t) - Math.exp(-beta * t)) / (beta - alpha);
phase = 1;
}
const sf = 1 - amplitude * phase;
return {
x: start.x + (end.x - start.x) * sf,
y: start.y + (end.y - start.y) * sf,
z: start.z + (end.z - start.z) * sf,
};
}
private elasticInterpolate(
start: Vector3,
end: Vector3,
t: number
): Vector3 {
if (t === 0) return start;
if (t === 1) return end;
const d = this.options.damping || DEFAULT_INTERPOLATION_OPTIONS.damping;
const p = 0.3;
const s = p / 4;
const postFix = 2 ** (10 * (t - 1));
const elasticT =
Math.sin(((t - s) * (2 * Math.PI)) / p) * postFix * d + 1;
return {
x: start.x + (end.x - start.x) * elasticT,
y: start.y + (end.y - start.y) * elasticT,
z: start.z + (end.z - start.z) * elasticT,
};
}
private bounceInterpolate(
start: Vector3,
end: Vector3,
t: number
): Vector3 {
if (t === 0) return start;
if (t === 1) return end;
let bt = t;
if (t < 4 / 11) bt = 7.5625 * t * t;
else if (t < 8 / 11) bt = 7.5625 * (t - 6 / 11) * (t - 6 / 11) + 0.75;
else if (t < 10 / 11)
bt = 7.5625 * (t - 9 / 11) * (t - 9 / 11) + 0.9375;
else bt = 7.5625 * (t - 21 / 22) * (t - 21 / 22) + 0.984375;
return {
x: start.x + (end.x - start.x) * bt,
y: start.y + (end.y - start.y) * bt,
z: start.z + (end.z - start.z) * bt,
};
}
private smoothstepInterpolate(
start: Vector3,
end: Vector3,
t: number
): Vector3 {
const st = this.smoothstep(0, 1, t);
return {
x: start.x + (end.x - start.x) * st,
y: start.y + (end.y - start.y) * st,
z: start.z + (end.z - start.z) * st,
};
}
private smoothstep(edge0: number, edge1: number, x: number): number {
const nx = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return nx * nx * (3 - 2 * nx);
}
private normalizeAngle(angle: number): number {
return ((angle + 180) % 360) - 180;
}
private clampPitch(pitch: number): number {
return Math.max(-90, Math.min(90, pitch));
}
setOptions(options: InterpolationOptions): void {
this.options = { ...this.options, ...options };
}
getOptions(): InterpolationOptions {
return { ...this.options };
}
}

View File

@@ -0,0 +1,94 @@
# Bitpacker Game OTel Instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OTel meter, tracer, counter, and histogram declarations for `@suaveplan/bitpacker-game`. Ships canonical instrumentation primitives for the `GameBitPacker`'s pack/unpack boundaries plus the three sub-systems (`prediction`, `interpolation`, `reconciliation`). The instruments file is the canonical source of truth for the package's metric and span vocabulary so consumers wiring a game pipeline can attach observability without re-deriving names.
The metric and span names follow the suaveplan convention: tracer/meter named `@suaveplan/bitpacker-game`; spans named `bitpacker_game.<operation>` (`pack`, `unpack`, `predict`, `interpolate`, `reconcile`); counters named `bitpacker_game.<scope>.<metric>`. This shape matches the rest of the bitpacker family.
## Features
- `getBitPackerGameTracer()` — returns the package's tracer
- `gameBitPackerOpsTotal` — counter labelled by `op` and `success`
- `gameBitPackerOpDurationMs` — histogram of pack/unpack duration
- `gameSubsystemOpsTotal` — counter labelled by `subsystem` and `success`
- `BITPACKER_GAME_SPAN_NAMES` — five canonical span names
## Usage: Basic Example
```ts
import {
BITPACKER_GAME_SPAN_NAMES,
gameBitPackerOpsTotal,
getBitPackerGameTracer,
} from "@suaveplan/bitpacker-game";
const tracer = getBitPackerGameTracer();
tracer.startActiveSpan(BITPACKER_GAME_SPAN_NAMES.pack, (span) => {
try {
// pack work
gameBitPackerOpsTotal.add(1, { op: "pack", success: "true" });
} finally {
span.end();
}
});
```
## Usage: Advanced Example
```ts
import {
BITPACKER_GAME_SPAN_NAMES,
gameSubsystemOpsTotal,
getBitPackerGameTracer,
} from "@suaveplan/bitpacker-game";
function runPredict(state: unknown) {
const tracer = getBitPackerGameTracer();
return tracer.startActiveSpan(
BITPACKER_GAME_SPAN_NAMES.predict,
(span) => {
try {
// predict
gameSubsystemOpsTotal.add(1, {
subsystem: "prediction",
success: "true",
});
return state;
} finally {
span.end();
}
}
);
}
```
## API Reference
### `getBitPackerGameTracer(): Tracer`
Returns the OTel `Tracer` instance for the package.
### `gameBitPackerOpsTotal: Counter`
Counter labelled by `op: "pack" | "unpack"` and `success: "true" | "false"`.
### `gameBitPackerOpDurationMs: Histogram`
Histogram of elapsed milliseconds per pack/unpack call, labelled by `op`.
### `gameSubsystemOpsTotal: Counter`
Counter labelled by `subsystem: "prediction" | "interpolation" | "reconciliation"` and `success`.
### `BITPACKER_GAME_SPAN_NAMES`
Const map: `{ pack, unpack, predict, interpolate, reconcile }` → canonical span names.
## Implementation Notes
- **Five sub-spans, one parent.** The pack/unpack span is the parent; predict/interpolate/reconcile spans are children when called from inside the pack/unpack hot path. Standalone calls produce a top-level span of the corresponding name.
- **No per-frame spans.** The game subsystems run at frame rate on real-time pipelines; one span per frame would explode trace volume. The counters fire per frame; the spans are reserved for boundary entries.

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BITPACKER_GAME_SPAN_NAMES,
gameBitPackerOpDurationMs,
gameBitPackerOpsTotal,
gameSubsystemOpsTotal,
getBitPackerGameTracer,
} from "./instruments.js";
describe("OTel instruments — declarations", () => {
it("exposes the canonical span names", () => {
expect(BITPACKER_GAME_SPAN_NAMES.pack).toBe("bitpacker_game.pack");
expect(BITPACKER_GAME_SPAN_NAMES.unpack).toBe("bitpacker_game.unpack");
expect(BITPACKER_GAME_SPAN_NAMES.predict).toBe(
"bitpacker_game.predict"
);
expect(BITPACKER_GAME_SPAN_NAMES.interpolate).toBe(
"bitpacker_game.interpolate"
);
expect(BITPACKER_GAME_SPAN_NAMES.reconcile).toBe(
"bitpacker_game.reconcile"
);
});
it("counters and histograms are constructed without error", () => {
expect(gameBitPackerOpsTotal).toBeDefined();
expect(gameBitPackerOpDurationMs).toBeDefined();
expect(gameSubsystemOpsTotal).toBeDefined();
});
it("tracer is reachable", () => {
expect(getBitPackerGameTracer()).toBeDefined();
});
it("counters accept the documented labels", () => {
expect(() =>
gameBitPackerOpsTotal.add(1, { op: "pack", success: "true" })
).not.toThrow();
expect(() =>
gameSubsystemOpsTotal.add(1, {
subsystem: "prediction",
success: "true",
})
).not.toThrow();
});
});

View File

@@ -0,0 +1,51 @@
import { metrics, type Tracer, trace } from "@opentelemetry/api";
const meter = metrics.getMeter("@suaveplan/bitpacker-game");
export function getBitPackerGameTracer(): Tracer {
return trace.getTracer("@suaveplan/bitpacker-game");
}
/**
* Counter incremented once per top-level `pack` / `unpack` performed by
* `GameBitPacker`. Labels: `op` and `success`.
*/
export const gameBitPackerOpsTotal = meter.createCounter(
"bitpacker_game.ops.total",
{
description: "Total GameBitPacker pack/unpack operations",
unit: "{ops}",
}
);
/**
* Histogram of operation duration in milliseconds, labelled by `op`.
*/
export const gameBitPackerOpDurationMs = meter.createHistogram(
"bitpacker_game.op.duration_ms",
{
description: "Duration of GameBitPacker pack/unpack in milliseconds",
unit: "ms",
}
);
/**
* Counter for prediction / interpolation / reconciliation operations,
* labelled by `subsystem` and `success`.
*/
export const gameSubsystemOpsTotal = meter.createCounter(
"bitpacker_game.subsystem.ops.total",
{
description:
"Total prediction/interpolation/reconciliation operations performed by bitpacker-game",
unit: "{ops}",
}
);
export const BITPACKER_GAME_SPAN_NAMES = {
pack: "bitpacker_game.pack",
unpack: "bitpacker_game.unpack",
predict: "bitpacker_game.predict",
interpolate: "bitpacker_game.interpolate",
reconcile: "bitpacker_game.reconcile",
} as const;

View File

@@ -0,0 +1,39 @@
## Prediction
**Path:** `src/prediction/`
**Exports:** `PositionPredictor`, `RotationPredictor`, motion math, Kalman filter, prediction algorithms
### What it does
Position and rotation prediction for game state extrapolation. Supports multiple algorithms from simple linear extrapolation to Kalman filtering.
### Key Classes
| Class | Description |
|-------|-------------|
| `PositionPredictor` | Predicts future positions from historical data |
| `RotationPredictor` | Predicts future rotations from historical data |
### Algorithms
| Algorithm | Description |
|-----------|-------------|
| `linearPredict` | Constant velocity extrapolation |
| `quadraticPredict` | Velocity + acceleration |
| `cubicPredict` | Velocity + acceleration + jerk |
| `hermitePredict` | Hermite spline extrapolation |
| `catmullRomPredict` | Catmull-Rom spline extrapolation |
| `verletPredict` | Physics-based Verlet integration |
| `kalmanPredict` | Kalman filter prediction |
### Adaptive Mode
`PositionPredictor` with `algorithm: ADAPTIVE` automatically switches algorithms based on prediction error:
- Error > 2× threshold → Kalman
- Error > threshold → Verlet
- Otherwise → Cubic
### Depends on
- `../types.js` — `Vector3`, `Rotation`, `PredictionOptions`, `KalmanState`

View File

@@ -0,0 +1,22 @@
export {
initKalmanState,
kalmanPredict,
updateKalmanFilter,
} from "./kalman-filter.js";
export {
calculateAcceleration,
calculateAccelerationAt,
calculateJerk,
calculateVelocity,
calculateVelocityAt,
} from "./motion-math.js";
export { PositionPredictor } from "./position-predictor.js";
export {
catmullRomPredict,
cubicPredict,
hermitePredict,
linearPredict,
quadraticPredict,
verletPredict,
} from "./prediction-algorithms.js";
export { RotationPredictor } from "./rotation-predictor.js";

View File

@@ -0,0 +1,8 @@
## kalman-filter
**Path:** `bitpacker-game/src/prediction/kalman-filter.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,105 @@
import type { HistoryPoint, KalmanState, Vector3 } from "../types.js";
export function initKalmanState(): KalmanState {
return {
position: { x: 0, y: 0, z: 0 },
velocity: { x: 0, y: 0, z: 0 },
positionCovariance: 1.0,
velocityCovariance: 1.0,
processNoise: 0.01,
measurementNoise: 0.1,
};
}
export function updateKalmanFilter(
state: KalmanState,
position: Vector3,
history: HistoryPoint<Vector3>[]
): KalmanState {
const dt = getTimeDelta(history);
if (dt <= 0) return state;
const predictedPosition = {
x: state.position.x + state.velocity.x * dt,
y: state.position.y + state.velocity.y * dt,
z: state.position.z + state.velocity.z * dt,
};
const predictedPositionCovariance =
state.positionCovariance +
dt * dt * state.velocityCovariance +
state.processNoise;
const predictedVelocityCovariance =
state.velocityCovariance + state.processNoise;
const positionKalmanGain =
predictedPositionCovariance /
(predictedPositionCovariance + state.measurementNoise);
const updatedPosition = {
x:
predictedPosition.x +
positionKalmanGain * (position.x - predictedPosition.x),
y:
predictedPosition.y +
positionKalmanGain * (position.y - predictedPosition.y),
z:
predictedPosition.z +
positionKalmanGain * (position.z - predictedPosition.z),
};
const prevItem = history[history.length - 2]!;
const prevPosition = prevItem.value;
const measuredVelocity = {
x: (position.x - prevPosition.x) / dt,
y: (position.y - prevPosition.y) / dt,
z: (position.z - prevPosition.z) / dt,
};
const velocityKalmanGain =
predictedVelocityCovariance /
(predictedVelocityCovariance + state.measurementNoise);
const updatedVelocity = {
x:
state.velocity.x +
velocityKalmanGain * (measuredVelocity.x - state.velocity.x),
y:
state.velocity.y +
velocityKalmanGain * (measuredVelocity.y - state.velocity.y),
z:
state.velocity.z +
velocityKalmanGain * (measuredVelocity.z - state.velocity.z),
};
return {
...state,
position: updatedPosition,
velocity: updatedVelocity,
positionCovariance:
(1 - positionKalmanGain) * predictedPositionCovariance,
velocityCovariance:
(1 - positionKalmanGain) * predictedVelocityCovariance,
};
}
export function kalmanPredict(
state: KalmanState | null,
dt: number,
fallback: () => Vector3
): Vector3 {
if (!state) return fallback();
return {
x: state.position.x + state.velocity.x * dt,
y: state.position.y + state.velocity.y * dt,
z: state.position.z + state.velocity.z * dt,
};
}
function getTimeDelta(history: HistoryPoint<Vector3>[]): number {
if (history.length < 2) return 0;
const last = history[history.length - 1];
const prev = history[history.length - 2];
if (!last || !prev) return 0;
return (last.timestamp - prev.timestamp) / 1000;
}

View File

@@ -0,0 +1,8 @@
## motion-math
**Path:** `bitpacker-game/src/prediction/motion-math.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,88 @@
import type { HistoryPoint, Vector3 } from "../types.js";
export function calculateVelocity(history: HistoryPoint<Vector3>[]): Vector3 {
if (history.length < 2) return { x: 0, y: 0, z: 0 };
const last = history[history.length - 1];
const prev = history[history.length - 2];
if (!last || !prev) return { x: 0, y: 0, z: 0 };
const dt = (last.timestamp - prev.timestamp) / 1000;
if (dt <= 0) return { x: 0, y: 0, z: 0 };
return {
x: (last.value.x - prev.value.x) / dt,
y: (last.value.y - prev.value.y) / dt,
z: (last.value.z - prev.value.z) / dt,
};
}
export function calculateVelocityAt(
history: HistoryPoint<Vector3>[],
index: number
): Vector3 {
if (index < 1 || index >= history.length) return { x: 0, y: 0, z: 0 };
const curr = history[index];
const prev = history[index - 1];
if (!curr || !prev) return { x: 0, y: 0, z: 0 };
const dt = (curr.timestamp - prev.timestamp) / 1000;
if (dt <= 0) return { x: 0, y: 0, z: 0 };
return {
x: (curr.value.x - prev.value.x) / dt,
y: (curr.value.y - prev.value.y) / dt,
z: (curr.value.z - prev.value.z) / dt,
};
}
export function calculateAcceleration(
history: HistoryPoint<Vector3>[]
): Vector3 {
if (history.length < 3) return { x: 0, y: 0, z: 0 };
const v1 = calculateVelocityAt(history, history.length - 1);
const v2 = calculateVelocityAt(history, history.length - 2);
const item1 = history[history.length - 1];
const item2 = history[history.length - 2];
if (!item1 || !item2) return { x: 0, y: 0, z: 0 };
const dt = (item1.timestamp - item2.timestamp) / 1000;
if (dt <= 0) return { x: 0, y: 0, z: 0 };
return {
x: (v1.x - v2.x) / dt,
y: (v1.y - v2.y) / dt,
z: (v1.z - v2.z) / dt,
};
}
export function calculateAccelerationAt(
history: HistoryPoint<Vector3>[],
index: number
): Vector3 {
if (index < 2 || index >= history.length) return { x: 0, y: 0, z: 0 };
const v1 = calculateVelocityAt(history, index);
const v2 = calculateVelocityAt(history, index - 1);
const item1 = history[index];
const item2 = history[index - 1];
if (!item1 || !item2) return { x: 0, y: 0, z: 0 };
const dt = (item1.timestamp - item2.timestamp) / 1000;
if (dt <= 0) return { x: 0, y: 0, z: 0 };
return {
x: (v1.x - v2.x) / dt,
y: (v1.y - v2.y) / dt,
z: (v1.z - v2.z) / dt,
};
}
export function calculateJerk(
history: HistoryPoint<Vector3>[],
calcAccelAt: (h: HistoryPoint<Vector3>[], i: number) => Vector3
): Vector3 {
if (history.length < 4) return { x: 0, y: 0, z: 0 };
const a1 = calcAccelAt(history, history.length - 1);
const a2 = calcAccelAt(history, history.length - 2);
const item1 = history[history.length - 1];
const item2 = history[history.length - 2];
if (!item1 || !item2) return { x: 0, y: 0, z: 0 };
const dt = (item1.timestamp - item2.timestamp) / 1000;
if (dt <= 0) return { x: 0, y: 0, z: 0 };
return {
x: (a1.x - a2.x) / dt,
y: (a1.y - a2.y) / dt,
z: (a1.z - a2.z) / dt,
};
}

View File

@@ -0,0 +1,8 @@
## position-predictor
**Path:** `bitpacker-game/src/prediction/position-predictor.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,183 @@
import {
DEFAULT_PREDICTION_OPTIONS,
type HistoryPoint,
type KalmanState,
PredictionAlgorithm,
type PredictionOptions,
type Vector3,
} from "../types.js";
import {
initKalmanState,
kalmanPredict,
updateKalmanFilter,
} from "./kalman-filter.js";
import {
calculateAcceleration,
calculateAccelerationAt,
calculateJerk,
calculateVelocity,
} from "./motion-math.js";
import {
catmullRomPredict,
cubicPredict,
hermitePredict,
linearPredict,
quadraticPredict,
verletPredict,
} from "./prediction-algorithms.js";
export class PositionPredictor {
private history: HistoryPoint<Vector3>[] = [];
private options: PredictionOptions;
private kalmanState: KalmanState | null = null;
private lastPrediction: Vector3 | null = null;
private lastPredictionError = 0;
private lastAlgorithm: PredictionAlgorithm;
constructor(options: PredictionOptions = {}) {
this.options = { ...DEFAULT_PREDICTION_OPTIONS, ...options };
this.lastAlgorithm =
this.options.algorithm || PredictionAlgorithm.LINEAR;
if (this.options.algorithm === PredictionAlgorithm.KALMAN) {
this.kalmanState = initKalmanState();
}
}
addPosition(position: Vector3, timestamp: number): void {
this.history.push({ value: { ...position }, timestamp });
const historySize =
this.options.historySize || DEFAULT_PREDICTION_OPTIONS.historySize;
if (this.history.length > historySize) {
this.history.shift();
}
if (
this.options.algorithm === PredictionAlgorithm.KALMAN &&
this.kalmanState
) {
this.kalmanState = updateKalmanFilter(
this.kalmanState,
position,
this.history
);
}
if (this.lastPrediction) {
this.lastPredictionError = Math.sqrt(
(this.lastPrediction.x - position.x) ** 2 +
(this.lastPrediction.y - position.y) ** 2 +
(this.lastPrediction.z - position.z) ** 2
);
if (this.options.algorithm === PredictionAlgorithm.ADAPTIVE) {
this.adaptAlgorithm();
}
}
}
private adaptAlgorithm(): void {
const threshold =
this.options.adaptiveThreshold ||
DEFAULT_PREDICTION_OPTIONS.adaptiveThreshold;
if (this.lastPredictionError > threshold * 2) {
this.lastAlgorithm = PredictionAlgorithm.KALMAN;
if (!this.kalmanState) this.kalmanState = initKalmanState();
} else if (this.lastPredictionError > threshold) {
this.lastAlgorithm = PredictionAlgorithm.VELOCITY_VERLET;
} else {
this.lastAlgorithm = PredictionAlgorithm.CUBIC;
}
}
predictPosition(deltaTime: number): Vector3 | null {
if (this.history.length < 2) return null;
const maxTime =
this.options.maxPredictionTime ||
DEFAULT_PREDICTION_OPTIONS.maxPredictionTime;
const limitedDeltaTime = Math.min(deltaTime, maxTime);
const dt = limitedDeltaTime / 1000;
const algorithm =
this.options.algorithm === PredictionAlgorithm.ADAPTIVE
? this.lastAlgorithm
: this.options.algorithm || PredictionAlgorithm.LINEAR;
const velocity = calculateVelocity(this.history);
const acceleration = calculateAcceleration(this.history);
let prediction: Vector3;
switch (algorithm) {
case PredictionAlgorithm.LINEAR:
prediction = linearPredict(this.history, velocity, dt);
break;
case PredictionAlgorithm.QUADRATIC:
prediction = quadraticPredict(
this.history,
velocity,
acceleration,
dt
);
break;
case PredictionAlgorithm.CUBIC: {
const jerk = calculateJerk(
this.history,
calculateAccelerationAt
);
prediction = cubicPredict(
this.history,
velocity,
acceleration,
jerk,
dt
);
break;
}
case PredictionAlgorithm.HERMITE:
prediction = hermitePredict(this.history, velocity, dt);
break;
case PredictionAlgorithm.CATMULL_ROM:
prediction = catmullRomPredict(this.history, dt);
break;
case PredictionAlgorithm.VELOCITY_VERLET:
prediction = verletPredict(
this.history,
velocity,
acceleration,
dt,
this.options
);
break;
case PredictionAlgorithm.KALMAN:
prediction = kalmanPredict(this.kalmanState, dt, () =>
linearPredict(this.history, velocity, dt)
);
if (!this.kalmanState) this.kalmanState = initKalmanState();
break;
default:
prediction = linearPredict(this.history, velocity, dt);
}
this.lastPrediction = { ...prediction };
return prediction;
}
getPredictionError(): number {
return this.lastPredictionError;
}
clearHistory(): void {
this.history = [];
this.lastPrediction = null;
this.lastPredictionError = 0;
if (this.options.algorithm === PredictionAlgorithm.KALMAN) {
this.kalmanState = initKalmanState();
}
}
setOptions(options: PredictionOptions): void {
this.options = { ...this.options, ...options };
if (
this.options.algorithm === PredictionAlgorithm.KALMAN &&
!this.kalmanState
) {
this.kalmanState = initKalmanState();
}
}
}

View File

@@ -0,0 +1,8 @@
## prediction-algorithms
**Path:** `bitpacker-game/src/prediction/prediction-algorithms.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,194 @@
import type { HistoryPoint, Vector3 } from "../types.js";
export function linearPredict(
history: HistoryPoint<Vector3>[],
velocity: Vector3,
dt: number
): Vector3 {
const lastItem = history[history.length - 1];
if (!lastItem) throw new Error("History item required for prediction");
const last = lastItem.value;
return {
x: last.x + velocity.x * dt,
y: last.y + velocity.y * dt,
z: last.z + velocity.z * dt,
};
}
export function quadraticPredict(
history: HistoryPoint<Vector3>[],
velocity: Vector3,
acceleration: Vector3,
dt: number
): Vector3 {
const lastItem = history[history.length - 1];
if (!lastItem) throw new Error("History item required for prediction");
const last = lastItem.value;
return {
x: last.x + velocity.x * dt + 0.5 * acceleration.x * dt * dt,
y: last.y + velocity.y * dt + 0.5 * acceleration.y * dt * dt,
z: last.z + velocity.z * dt + 0.5 * acceleration.z * dt * dt,
};
}
export function cubicPredict(
history: HistoryPoint<Vector3>[],
velocity: Vector3,
acceleration: Vector3,
jerk: Vector3,
dt: number
): Vector3 {
const lastItem = history[history.length - 1];
if (!lastItem) throw new Error("History item required for prediction");
const last = lastItem.value;
return {
x:
last.x +
velocity.x * dt +
0.5 * acceleration.x * dt * dt +
(1 / 6) * jerk.x * dt * dt * dt,
y:
last.y +
velocity.y * dt +
0.5 * acceleration.y * dt * dt +
(1 / 6) * jerk.y * dt * dt * dt,
z:
last.z +
velocity.z * dt +
0.5 * acceleration.z * dt * dt +
(1 / 6) * jerk.z * dt * dt * dt,
};
}
export function hermitePredict(
history: HistoryPoint<Vector3>[],
velocity: Vector3,
dt: number
): Vector3 {
if (history.length < 3) return linearPredict(history, velocity, dt);
const item0 = history[history.length - 3];
const item1 = history[history.length - 2];
const item2 = history[history.length - 1];
if (!item0 || !item1 || !item2)
throw new Error("History items required for Hermite prediction");
const p0 = item0.value;
const p1 = item1.value;
const p2 = item2.value;
const t0 = item0.timestamp;
const t1 = item1.timestamp;
const t2 = item1.timestamp;
const m0 = {
x: ((p1.x - p0.x) / (t1 - t0)) * 1000,
y: ((p1.y - p0.y) / (t1 - t0)) * 1000,
z: ((p1.z - p0.z) / (t1 - t0)) * 1000,
};
const m1 = {
x: ((p2.x - p1.x) / (t2 - t1)) * 1000,
y: ((p2.y - p1.y) / (t2 - t1)) * 1000,
z: ((p2.z - p1.z) / (t2 - t1)) * 1000,
};
const t = dt / ((t2 - t1) / 1000);
const h00 = 2 * t * t * t - 3 * t * t + 1;
const h10 = t * t * t - 2 * t * t + t;
const h01 = -2 * t * t * t + 3 * t * t;
const h11 = t * t * t - t * t;
return {
x: h00 * p1.x + h10 * m0.x + h01 * p2.x + h11 * m1.x,
y: h00 * p1.y + h10 * m0.y + h01 * p2.y + h11 * m1.y,
z: h00 * p1.z + h10 * m0.z + h01 * p2.z + h11 * m1.z,
};
}
export function catmullRomPredict(
history: HistoryPoint<Vector3>[],
dt: number
): Vector3 {
if (history.length < 4) {
const last = history[history.length - 1];
const prev = history[history.length - 2];
if (!last || !prev) return { x: 0, y: 0, z: 0 };
const timeDt = (last.timestamp - prev.timestamp) / 1000;
const vel =
timeDt > 0
? {
x: (last.value.x - prev.value.x) / timeDt,
y: (last.value.y - prev.value.y) / timeDt,
z: (last.value.z - prev.value.z) / timeDt,
}
: { x: 0, y: 0, z: 0 };
return linearPredict(history, vel, dt);
}
const item0 = history[history.length - 4];
const item1 = history[history.length - 3];
const item2 = history[history.length - 2];
const item3 = history[history.length - 1];
if (!item0 || !item1 || !item2 || !item3)
throw new Error("History items required for Catmull-Rom prediction");
const p0 = item0.value;
const p1 = item1.value;
const p2 = item2.value;
const p3 = item3.value;
const t1 = item1.timestamp;
const t2 = item2.timestamp;
const t = dt / ((t2 - t1) / 1000);
const t2s = t * t;
const t3c = t2s * t;
return {
x:
0.5 *
(2 * p1.x +
(-p0.x + p2.x) * t +
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2s +
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3c),
y:
0.5 *
(2 * p1.y +
(-p0.y + p2.y) * t +
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2s +
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3c),
z:
0.5 *
(2 * p1.z +
(-p0.z + p2.z) * t +
(2 * p0.z - 5 * p1.z + 4 * p2.z - p3.z) * t2s +
(-p0.z + 3 * p1.z - 3 * p2.z + p3.z) * t3c),
};
}
export function verletPredict(
history: HistoryPoint<Vector3>[],
velocity: Vector3,
acceleration: Vector3,
dt: number,
options: { gravityFactor?: number; velocityDamping?: number }
): Vector3 {
const lastItem = history[history.length - 1];
if (!lastItem) throw new Error("History item required for prediction");
const last = lastItem.value;
const gravityFactor = options.gravityFactor ?? 9.8;
const gravityAccel = { x: 0, y: 0, z: -gravityFactor };
const damping = options.velocityDamping ?? 0.98;
const dampedVelocity = {
x: velocity.x * damping ** dt,
y: velocity.y * damping ** dt,
z: velocity.z * damping ** dt,
};
return {
x: last.x + dampedVelocity.x * dt + 0.5 * acceleration.x * dt * dt,
y: last.y + dampedVelocity.y * dt + 0.5 * acceleration.y * dt * dt,
z:
last.z +
dampedVelocity.z * dt +
0.5 * (acceleration.z + gravityAccel.z) * dt * dt,
};
}

View File

@@ -0,0 +1,80 @@
# Prediction Subsystem
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - rotation-predictor — **Path:** `bitpacker-game/src/…
- [rotation-predictor](./rotation-predictor.md) — **Path:** `bitpacker-game/src/prediction/rotation-predictor.ts` **Export:** See source for details.
- rotation-predictor — **Path:** `bitpacker-game/src/…
- [rotation-predictor](./rotation-predictor.md) — **Path:** `bitpacker-game/src/prediction/rotation-predictor.ts` **Export:** See source for details.
- rotation-predictor — **Path:** `bitpacker-game/src/…
- [rotation-predictor](./rotation-predictor.md) — **Path:** `bitpacker-game/src/prediction/rotation-predictor.ts` **Export:** See source for details.
- rotation-predictor — **Path:** `bitpacker-game/src/…
- [rotation-predictor](./rotation-predictor.md) — **Path:** `bitpacker-game/src/prediction/rotation-predictor.ts` **Export:** See source for details.
- [rotation-predictor](./rotation-predictor.md) — **Path:** `bitpacker-game/src/prediction/rotation-predictor.ts` **Export:** See source for details.
## Purpose
Position and rotation extrapolation primitives for the `GameBitPacker`. The module is a re-export barrel that surfaces every prediction algorithm — Kalman filter, motion-math derivatives (velocity, acceleration, jerk), per-algorithm predictor classes (`PositionPredictor`, `RotationPredictor`), and the seven supported extrapolation algorithms (linear, quadratic, cubic, hermite, catmull-rom, velocity-verlet, kalman). Consumers pick an algorithm via the `PredictionAlgorithm` enum on the `predictionOptions` they pass to `GameBitPacker`.
The Kalman family is the most accurate (model-based filter that learns process and measurement noise from observed updates). The polynomial family (linear / quadratic / cubic) is cheap and good enough for slow-moving entities. Hermite and Catmull-Rom interpolation produce smooth trajectories from sparse samples and are the right pick for cinematic camera or replay playback. Velocity-Verlet is the staple for physics-driven motion where acceleration is known.
## Features
- `initKalmanState`, `kalmanPredict`, `updateKalmanFilter` — Kalman filter primitives
- `calculateVelocity`, `calculateAcceleration`, `calculateJerk`, `calculateVelocityAt`, `calculateAccelerationAt` — discrete-difference motion math
- `linearPredict`, `quadraticPredict`, `cubicPredict`, `hermitePredict`, `catmullRomPredict`, `verletPredict` — six pure extrapolation functions
- `PositionPredictor`, `RotationPredictor` — stateful predictors that remember a rolling history and pick the algorithm at construction
## Usage: Basic Example
```ts
import { PositionPredictor, PredictionAlgorithm } from "@suaveplan/bitpacker-game";
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.LINEAR,
historySize: 5,
});
predictor.observe({ x: 0, y: 0, z: 0 }, 0);
predictor.observe({ x: 1, y: 0, z: 0 }, 16);
predictor.observe({ x: 2, y: 0, z: 0 }, 32);
const next = predictor.predict(48); // { x: 3, y: 0, z: 0 }
```
## Usage: Advanced Example
```ts
import {
initKalmanState,
kalmanPredict,
updateKalmanFilter,
calculateVelocity,
} from "@suaveplan/bitpacker-game";
let kf = initKalmanState({ x: 0, y: 0, z: 0 });
function frame(observation: { x: number; y: number; z: number }, dt: number) {
kf = updateKalmanFilter(kf, observation, dt);
const predicted = kalmanPredict(kf, dt);
const v = calculateVelocity(predicted, kf.position, dt);
return { predicted, velocity: v };
}
```
## API Reference
This module is a barrel re-export; see the per-algorithm `.md` files (when they ship) for full per-function semantics. The headline exports are:
- `PositionPredictor` and `RotationPredictor` — stateful predictors with `observe(value, timestamp)` and `predict(timestamp)` methods
- The six pure prediction functions, each `(history: Vector3[], dt: number) => Vector3`
- `initKalmanState(initial)`, `updateKalmanFilter(state, obs, dt)`, `kalmanPredict(state, dt)` for direct Kalman usage
## Implementation Notes
- **Algorithm choice is a behaviour decision.** The `PredictionAlgorithm` enum is open via declaration merging, but each value implies different observation requirements: linear needs 2 samples, quadratic 3, cubic 4, Catmull-Rom 4, Hermite 2 + tangents, Verlet 2 + acceleration, Kalman a fitted state. Picking an algorithm without enough history degrades to a fallback (typically linear).
- **`historySize` is a soft cap, not a hard one.** The predictor stores up to `historySize` samples in a ring buffer; older samples are dropped on overflow. A larger buffer improves polynomial fit accuracy but raises latency on unstable observations.
- **No coordinate-system assumption.** All predictors operate on a generic `Vector3` (`{ x, y, z }`); rotations are wrapped to `[-π, π]` by `RotationPredictor` before extrapolation to avoid spurious 2π jumps on yaw wrap.
- **Process / measurement noise are tunable.** Kalman state exposes `processNoise` and `measurementNoise`. The defaults are tuned for 60 Hz simulation; faster sample rates need lower noise values to avoid over-smoothing.

View File

@@ -0,0 +1,27 @@
/**
* Game state prediction — position and rotation extrapolation.
* @module @suaveplan/bitpacker/game/prediction
*/
export {
initKalmanState,
kalmanPredict,
updateKalmanFilter,
} from "./kalman-filter.js";
export {
calculateAcceleration,
calculateAccelerationAt,
calculateJerk,
calculateVelocity,
calculateVelocityAt,
} from "./motion-math.js";
export { PositionPredictor } from "./position-predictor.js";
export {
catmullRomPredict,
cubicPredict,
hermitePredict,
linearPredict,
quadraticPredict,
verletPredict,
} from "./prediction-algorithms.js";
export { RotationPredictor } from "./rotation-predictor.js";

View File

@@ -0,0 +1,8 @@
## rotation-predictor
**Path:** `bitpacker-game/src/prediction/rotation-predictor.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,103 @@
import {
DEFAULT_PREDICTION_OPTIONS,
type HistoryPoint,
type PredictionOptions,
type Rotation,
} from "../types.js";
export class RotationPredictor {
private history: HistoryPoint<Rotation>[] = [];
private options: PredictionOptions;
private lastPrediction: Rotation | null = null;
private lastPredictionError = 0;
constructor(options: PredictionOptions = {}) {
this.options = { ...DEFAULT_PREDICTION_OPTIONS, ...options };
}
addRotation(rotation: Rotation, timestamp: number): void {
this.history.push({ value: { ...rotation }, timestamp });
const historySize =
this.options.historySize || DEFAULT_PREDICTION_OPTIONS.historySize;
if (this.history.length > historySize) {
this.history.shift();
}
if (this.lastPrediction && this.history.length >= 2) {
this.lastPredictionError = Math.sqrt(
this.angleDifference(this.lastPrediction.yaw, rotation.yaw) **
2 +
this.angleDifference(
this.lastPrediction.pitch,
rotation.pitch
) **
2
);
}
}
private angleDifference(a: number, b: number): number {
let diff = ((b - a + 180) % 360) - 180;
if (diff < -180) diff += 360;
return diff;
}
predictRotation(deltaTime: number): Rotation | null {
if (this.history.length < 2) return null;
const maxTime =
this.options.maxPredictionTime ||
DEFAULT_PREDICTION_OPTIONS.maxPredictionTime;
const limitedDeltaTime = Math.min(deltaTime, maxTime);
const dt = limitedDeltaTime / 1000;
const prediction = this.linearPredictRotation(dt);
this.lastPrediction = { ...prediction };
return prediction;
}
private linearPredictRotation(dt: number): Rotation {
const lastItem = this.history[this.history.length - 1];
if (!lastItem)
throw new Error("History item required for rotation prediction");
const last = lastItem.value;
const angularVelocity = this.calculateAngularVelocity();
return {
yaw: this.normalizeAngle(last.yaw + angularVelocity.yaw * dt),
pitch: this.clampPitch(last.pitch + angularVelocity.pitch * dt),
};
}
private calculateAngularVelocity(): Rotation {
if (this.history.length < 2) return { yaw: 0, pitch: 0 };
const last = this.history[this.history.length - 1];
const prev = this.history[this.history.length - 2];
if (!last || !prev) return { yaw: 0, pitch: 0 };
const dt = (last.timestamp - prev.timestamp) / 1000;
if (dt <= 0) return { yaw: 0, pitch: 0 };
return {
yaw: this.angleDifference(prev.value.yaw, last.value.yaw) / dt,
pitch:
this.angleDifference(prev.value.pitch, last.value.pitch) / dt,
};
}
private normalizeAngle(angle: number): number {
return ((angle + 180) % 360) - 180;
}
private clampPitch(pitch: number): number {
return Math.max(-90, Math.min(90, pitch));
}
getPredictionError(): number {
return this.lastPredictionError;
}
clearHistory(): void {
this.history = [];
this.lastPrediction = null;
this.lastPredictionError = 0;
}
setOptions(options: PredictionOptions): void {
this.options = { ...this.options, ...options };
}
}

View File

@@ -0,0 +1,84 @@
# Game Quantization Helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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 a `Vector3` to a precision divisor
- `quantizePosition2D(position, precision = 0.01)` — round a `Vector2`
- `quantizeRotation(rotation, yawPrecision = 1, pitchPrecision = 1)` — round yaw + pitch
- `interpolateAngle(a, b, t)` — interpolate angles in degrees, taking the short arc through the wrap
- `validateRange(value, min, max, name?)` — throw if out of range, with an optional field name for the error
## Usage: Basic Example
```ts
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
```ts
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) * precision` produces 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 `validateRange` if your wire format has a hard cap. Quantizing without clamping can produce values outside the schema's bit-width and cause pack failures.
- **`interpolateAngle` is 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.** `validateRange` is `O(1)` and emits a clear error message including the field name; it's appropriate at every API boundary, not just hot-path entries.

View File

@@ -0,0 +1,125 @@
/**
* Quantization utilities for GameBitPacker
*/
import type { Rotation, Vector2, Vector3 } from "../types.js";
/**
* Quantize 3D position
*/
export function quantizePosition(position: Vector3, precision = 0.01): Vector3 {
return {
x: Math.round(position.x / precision) * precision,
y: Math.round(position.y / precision) * precision,
z: Math.round(position.z / precision) * precision,
};
}
/**
* Quantize 2D position
*/
export function quantizePosition2D(
position: Vector2,
precision = 0.1
): Vector2 {
return {
x: Math.round(position.x / precision) * precision,
y: Math.round(position.y / precision) * precision,
};
}
/**
* Quantize rotation (yaw/pitch format only)
*/
export function quantizeRotation(
rotation: Rotation,
precision = 0.1
): Rotation {
return {
yaw: Math.round(rotation.yaw / precision) * precision,
pitch: Math.round(rotation.pitch / precision) * precision,
};
}
/**
* Interpolate angle with proper wrapping
*/
export function interpolateAngle(
start: number,
end: number,
t: number
): number {
// Normalize angles to [-180, 180]
const normalizeAngle = (angle: number): number => {
let normalized = angle % 360;
if (normalized > 180) normalized -= 360;
if (normalized < -180) normalized += 360;
return normalized;
};
const normalizedStart = normalizeAngle(start);
const normalizedEnd = normalizeAngle(end);
// Find shortest path
let diff = normalizedEnd - normalizedStart;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
let result = normalizedStart + diff * t;
// Normalize result to [-180, 180]
result = normalizeAngle(result);
// Handle the edge case where 180 and -180 are equivalent
// If we're very close to 180, use -180 instead for consistency
if (Math.abs(result - 180) < 0.0001) {
result = -180;
}
return result;
}
/**
* Validate numeric range
*/
export function validateRange(
value: number,
schemaRange: string | number
): boolean {
if (typeof schemaRange === "number") {
// It's a max value only, validate 0 to max
return value >= 0 && value <= schemaRange;
}
// Handle decimal precision format like "-1024.2"
const decimalMatch = schemaRange.match(/^(-?\d+)\.(\d+)$/);
if (decimalMatch) {
const [, rangeStr] = decimalMatch;
if (!rangeStr) return true;
const range = Number.parseInt(rangeStr, 10);
if (range < 0) {
// Negative means it's a range from negative to positive
return value >= range && value <= Math.abs(range);
}
// Positive means it's a range from 0 to positive
return value >= 0 && value <= range;
}
// Handle range format like "-1024:1024"
const rangeMatch = schemaRange.match(/^(-?\d*):(-?\d*)$/);
if (!rangeMatch) {
return true; // Invalid range format, skip validation
}
const [, minStr, maxStr] = rangeMatch;
const min =
!minStr || minStr === ""
? Number.NEGATIVE_INFINITY
: Number.parseInt(minStr, 10);
const max =
!maxStr || maxStr === ""
? Number.POSITIVE_INFINITY
: Number.parseInt(maxStr, 10);
return value >= min && value <= max;
}

View File

@@ -0,0 +1,17 @@
/**
* Types for Game Quantization utilities
*/
import type { Rotation, Vector2, Vector3 } from "../types.js";
export interface QuantizationOptions {
precision?: number;
}
export interface QuantizationUtils {
quantizePosition(position: Vector3, precision?: number): Vector3;
quantizePosition2D(position: Vector2, precision?: number): Vector2;
quantizeRotation(rotation: Rotation, precision?: number): Rotation;
interpolateAngle(start: number, end: number, t: number): number;
validateRange(value: number, schemaRange: string | number): boolean;
}

View File

@@ -0,0 +1,41 @@
## Reconciliation
**Path:** `src/reconciliation/reconciliation.ts`
**Export:** `StateReconciliation`
### What it does
Client-side prediction and server reconciliation for multiplayer games. Buffers inputs and server states, detects mispredictions, and smoothly reconciles client state.
### Key API
| Method | Description |
|--------|-------------|
| `processInput(input, timestamp)` | Buffer a player input, return sequence |
| `predictClientState(state, applyPhysics, deltaTime)` | Apply unprocessed inputs locally |
| `processServerUpdate(state, sequence, timestamp)` | Receive server-authoritative state |
| `reconcileState(state, applyPhysics)` | Rewind + replay after server correction |
| `extrapolateState(state, deltaTime)` | Predict forward for missing server data |
| `sendPing()` / `receivePong()` | Latency measurement |
### Buffers
- **Input buffer** — stores client inputs with sequence numbers
- **State buffer** — stores predicted and server states
- **Jitter buffer** — optional delay for out-of-order server packets
### Reconciliation Flow
1. Client predicts state from inputs
2. Server sends authoritative state
3. If prediction error > threshold, reconcile:
- Find server state in buffer
- Rewind to server state
- Replay all inputs after that point
- Smoothly interpolate to reconciled state
### Depends on
- `../types.js` — `PlayerState`, `PlayerInput`, `ReconciliationOptions`
- `../prediction/` — `PositionPredictor`
- `../interpolation/` — `GameInterpolator`

View File

@@ -0,0 +1 @@
export { StateReconciliation } from "./reconciliation.js";

View File

@@ -0,0 +1,12 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## reconciliation
**Path:** `bitpacker-game/src/reconciliation/reconciliation.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,478 @@
import type { BitPackerSchema } from "@suaveplan/types";
import { GameInterpolator } from "../interpolation/interpolation.js";
import { PositionPredictor } from "../prediction/prediction.js";
import {
DEFAULT_RECONCILIATION_OPTIONS,
type IGameBitPacker,
type InputBufferEntry,
InterpolationMethodEnum,
type IStateReconciliation,
type PlayerInput,
type PlayerState,
PredictionAlgorithm,
type ReconciliationOptions,
type StateBufferEntry,
} from "../types.js";
/**
* Class for client-side prediction and server reconciliation
*/
export class StateReconciliation<T extends BitPackerSchema>
implements IStateReconciliation<T>
{
private options: Required<ReconciliationOptions>;
private inputBuffer: InputBufferEntry[] = [];
private stateBuffer: StateBufferEntry[] = [];
private lastServerSequence = -1;
private lastClientSequence = -1;
private serverTimeOffset = 0;
private positionPredictor: PositionPredictor;
private interpolator: GameInterpolator;
private jitterBuffer: StateBufferEntry[] = [];
private pingHistory: number[] = [];
private lastPingTime = 0;
private averagePing = 0;
private lastReconciliationError = 0;
private reconciliationCount = 0;
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: Used via setBitPacker()
private bitPacker!: IGameBitPacker<T>;
/**
* Create a new state reconciliation instance
* @param schema BitPacker schema for game state
* @param options Reconciliation options
*/
constructor(_schema: T, options: ReconciliationOptions = {}) {
this.options = { ...DEFAULT_RECONCILIATION_OPTIONS, ...options };
this.positionPredictor = new PositionPredictor(
this.options.predictionOptions || {
algorithm: PredictionAlgorithm.ADAPTIVE,
historySize: 10,
}
);
this.interpolator = new GameInterpolator(
this.options.interpolationOptions || {
method: InterpolationMethodEnum.HERMITE,
}
);
}
/**
* Set the bit packer instance
* @param bitPacker The game bit packer to use
*/
public setBitPacker(bitPacker: IGameBitPacker<T>): void {
this.bitPacker = bitPacker;
}
/**
* Process a player input on the client side
* @param input Player input
* @param timestamp Current timestamp
* @returns Sequence number assigned to this input
*/
public processInput(input: PlayerInput, timestamp: number): number {
const sequence = this.lastClientSequence + 1;
this.lastClientSequence = sequence;
this.inputBuffer.push({
input,
timestamp,
sequence,
processed: false,
predicted: false,
});
const bufferSize =
this.options.inputBufferSize ??
DEFAULT_RECONCILIATION_OPTIONS.inputBufferSize;
if (this.inputBuffer.length > bufferSize) {
this.inputBuffer.shift();
}
return sequence;
}
/**
* Apply client-side prediction
* @param currentState Current player state
* @param applyPhysics Function to apply physics/game logic to state
* @param deltaTime Time since last update
* @returns Predicted state
*/
public predictClientState(
currentState: PlayerState,
applyPhysics: (
state: PlayerState,
input: PlayerInput,
deltaTime: number
) => PlayerState,
deltaTime: number
): PlayerState {
const unprocessedInputs = this.inputBuffer.filter(
(entry) => !entry.processed
);
if (unprocessedInputs.length === 0) {
return currentState;
}
let predictedState = { ...currentState };
for (const entry of unprocessedInputs) {
predictedState = applyPhysics(
predictedState,
entry.input,
deltaTime
);
entry.processed = true;
entry.predicted = true;
}
this.stateBuffer.push({
state: predictedState,
timestamp: Date.now(),
sequence: this.lastClientSequence,
isServerState: false,
});
const bufferSize = this.options.stateBufferSize;
if (this.stateBuffer.length > bufferSize) {
this.stateBuffer.shift();
}
this.positionPredictor.addPosition(predictedState.position, Date.now());
return predictedState;
}
/**
* Process a server state update
* @param serverState Server state
* @param serverSequence Sequence number from server
* @param serverTimestamp Server timestamp
* @returns Whether reconciliation is needed
*/
public processServerUpdate(
serverState: PlayerState,
serverSequence: number,
serverTimestamp: number
): boolean {
this.updateServerTimeOffset(serverTimestamp);
if (this.options.enableJitterBuffer) {
this.jitterBuffer.push({
state: serverState,
timestamp: serverTimestamp,
sequence: serverSequence,
isServerState: true,
});
this.jitterBuffer.sort((a, b) => a.sequence - b.sequence);
const jitterSize =
this.options.jitterBufferSize ??
DEFAULT_RECONCILIATION_OPTIONS.jitterBufferSize;
if (this.jitterBuffer.length > jitterSize) {
const oldestState = this.jitterBuffer.shift()!;
return this.processServerState(
oldestState.state,
oldestState.sequence,
oldestState.timestamp
);
}
return false;
}
return this.processServerState(
serverState,
serverSequence,
serverTimestamp
);
}
/**
* Process a server state (after jitter buffer if enabled)
*/
private processServerState(
serverState: PlayerState,
serverSequence: number,
serverTimestamp: number
): boolean {
if (serverSequence <= this.lastServerSequence) {
return false;
}
this.stateBuffer.push({
state: serverState,
timestamp: serverTimestamp,
sequence: serverSequence,
isServerState: true,
});
const bufferSize = this.options.stateBufferSize;
if (this.stateBuffer.length > bufferSize) {
this.stateBuffer.shift();
}
const inputEntry = this.inputBuffer.find(
(entry) => entry.sequence === serverSequence
);
if (inputEntry?.predicted) {
const predictedEntry = this.stateBuffer.find(
(entry) =>
entry.sequence === serverSequence && !entry.isServerState
);
if (predictedEntry) {
const error = this.calculateStateError(
predictedEntry.state,
serverState
);
this.lastReconciliationError = error;
const threshold = this.options.reconciliationThreshold;
if (error > threshold) {
this.reconciliationCount++;
this.lastServerSequence = serverSequence;
return true;
}
}
}
this.lastServerSequence = serverSequence;
return false;
}
/**
* Reconcile client state with server state
*/
public reconcileState(
currentState: PlayerState,
applyPhysics: (
state: PlayerState,
input: PlayerInput,
deltaTime: number
) => PlayerState
): PlayerState {
const serverStateEntry = this.findLatestServerState();
if (!serverStateEntry) {
return currentState;
}
const laterInputs = this.inputBuffer.filter(
(entry) => entry.sequence > serverStateEntry.sequence
);
let reconciledState = { ...serverStateEntry.state };
for (const entry of laterInputs) {
let deltaTime = 16;
const prevInput = this.inputBuffer.find(
(e) => e.sequence === entry.sequence - 1
);
if (prevInput) {
deltaTime = entry.timestamp - prevInput.timestamp;
}
reconciledState = applyPhysics(
reconciledState,
entry.input,
deltaTime / 1000
);
}
const smoothingFactor = this.options.smoothingFactor;
const smoothedState = this.interpolator.interpolatePlayerState(
currentState,
reconciledState,
smoothingFactor
);
return smoothedState;
}
private findLatestServerState(): StateBufferEntry | undefined {
const serverStates = this.stateBuffer.filter(
(entry) => entry.isServerState
);
if (serverStates.length === 0) {
return undefined;
}
serverStates.sort((a, b) => b.sequence - a.sequence);
return serverStates[0];
}
private calculateStateError(
state1: PlayerState,
state2: PlayerState
): number {
const posError = Math.sqrt(
(state1.position.x - state2.position.x) ** 2 +
(state1.position.y - state2.position.y) ** 2 +
(state1.position.z - state2.position.z) ** 2
);
const yawDiff = Math.abs(
this.angleDifference(state1.rotation.yaw, state2.rotation.yaw)
);
const pitchDiff = Math.abs(
state1.rotation.pitch - state2.rotation.pitch
);
const rotError =
Math.sqrt(yawDiff * yawDiff + pitchDiff * pitchDiff) / 180;
return posError * 0.8 + rotError * 0.2;
}
private angleDifference(a: number, b: number): number {
let diff = ((b - a + 180) % 360) - 180;
if (diff < -180) diff += 360;
return diff;
}
private updateServerTimeOffset(serverTimestamp: number): void {
const now = Date.now();
const oneWayDelay = this.getEstimatedServerDelay() / 2;
const estimatedServerTime = now - oneWayDelay;
const newOffset = serverTimestamp - estimatedServerTime;
if (this.options.enableRollingAverage) {
const window =
this.options.rollingAverageWindow ??
DEFAULT_RECONCILIATION_OPTIONS.rollingAverageWindow;
if (this.serverTimeOffset === 0) {
this.serverTimeOffset = newOffset;
} else {
this.serverTimeOffset =
(this.serverTimeOffset * (window - 1) + newOffset) / window;
}
} else {
this.serverTimeOffset = newOffset;
}
}
private getEstimatedServerDelay(): number {
if (this.pingHistory.length === 0) {
return (
this.options.serverDelay ??
DEFAULT_RECONCILIATION_OPTIONS.serverDelay
);
}
return this.averagePing;
}
public updatePing(pingTime: number): void {
this.pingHistory.push(pingTime);
const window =
this.options.rollingAverageWindow ??
DEFAULT_RECONCILIATION_OPTIONS.rollingAverageWindow;
if (this.pingHistory.length > window) {
this.pingHistory.shift();
}
this.averagePing =
this.pingHistory.reduce((sum, ping) => sum + ping, 0) /
this.pingHistory.length;
}
public sendPing(): void {
this.lastPingTime = Date.now();
}
public receivePong(): void {
const pingTime = Date.now() - this.lastPingTime;
this.updatePing(pingTime);
}
public extrapolateState(
currentState: PlayerState,
deltaTime: number
): PlayerState {
if (!this.options.enableExtrapolation) {
return currentState;
}
const maxTime =
this.options.maxExtrapolationTime ??
DEFAULT_RECONCILIATION_OPTIONS.maxExtrapolationTime;
const limitedDeltaTime = Math.min(deltaTime, maxTime);
const predictedPosition =
this.positionPredictor.predictPosition(limitedDeltaTime);
if (!predictedPosition) {
return currentState;
}
return {
...currentState,
position: predictedPosition,
};
}
public getClientSequence(): number {
return this.lastClientSequence;
}
public getServerSequence(): number {
return this.lastServerSequence;
}
public getReconciliationError(): number {
return this.lastReconciliationError;
}
public getReconciliationCount(): number {
return this.reconciliationCount;
}
public getServerTimeOffset(): number {
return this.serverTimeOffset;
}
public getAveragePing(): number {
return this.averagePing;
}
public reset(): void {
this.inputBuffer = [];
this.stateBuffer = [];
this.jitterBuffer = [];
this.lastServerSequence = -1;
this.lastClientSequence = -1;
this.serverTimeOffset = 0;
this.positionPredictor.clearHistory();
this.pingHistory = [];
this.lastPingTime = 0;
this.averagePing = 0;
this.lastReconciliationError = 0;
this.reconciliationCount = 0;
}
public setOptions(options: ReconciliationOptions): void {
this.options = { ...this.options, ...options };
if (options.predictionOptions) {
this.positionPredictor.setOptions(options.predictionOptions);
}
if (options.interpolationOptions) {
this.interpolator.setOptions(options.interpolationOptions);
}
}
public getOptions(): ReconciliationOptions {
return { ...this.options };
}
}

View File

@@ -0,0 +1,5 @@
export {
BITPACKER_GAME_SCHEMA_VERSION,
BITPACKER_GAME_SUBJECT,
registerBitPackerGameSchemas,
} from "./register-schemas.js";

View File

@@ -0,0 +1,65 @@
# Register Schemas — `@suaveplan/bitpacker-game`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-game` package. Exposes `registerBitPackerGameSchemas(registry)` which converts the package's six public boundary schemas into JSON Schema form and registers them under `bitpacker:@suaveplan/bitpacker-game` at version 1. Re-registration on the same registry is a no-op.
The six schemas covered are: `predictionOptions`, `interpolationOptions`, `reconciliationOptions` (the three sub-options bundles), `gameStateSchema` (the `{ version, entities }` envelope), `quantizedVector` (the `quantizeVector` / `dequantizeVector` input), and `gameBitPackerOptions` (the top-level `GameBitPacker` constructor bundle). Together they describe every external configuration shape the package accepts.
## Features
- `BITPACKER_GAME_SUBJECT` constant — `"bitpacker:@suaveplan/bitpacker-game"`
- `BITPACKER_GAME_SCHEMA_VERSION` constant — `1`
- `registerBitPackerGameSchemas(registry)` — idempotent registration
## Usage: Basic Example
```ts
import { createRegistry } from "@suaveplan/schema-registry";
import {
BITPACKER_GAME_SUBJECT,
registerBitPackerGameSchemas,
} from "@suaveplan/bitpacker-game/register-schemas";
const registry = createRegistry();
registerBitPackerGameSchemas(registry);
```
## Usage: Advanced Example
```ts
import { createRegistry, SchemaRegistry } from "@suaveplan/schema-registry";
import { registerBitPackerSchemas } from "@suaveplan/bitpacker/register-schemas";
import { registerBitPackerGameSchemas } from "@suaveplan/bitpacker-game/register-schemas";
function bootstrapAll(registry: SchemaRegistry) {
registerBitPackerSchemas(registry);
registerBitPackerGameSchemas(registry);
}
const registry = createRegistry();
bootstrapAll(registry);
```
## API Reference
### `BITPACKER_GAME_SUBJECT: "bitpacker:@suaveplan/bitpacker-game"`
Const string.
### `BITPACKER_GAME_SCHEMA_VERSION: 1`
Const number.
### `registerBitPackerGameSchemas(registry: SchemaRegistry): void`
Idempotent registration. Returns without writing if the subject is already present.
## Implementation Notes
- **Six-key payload.** One key per public boundary schema.
- **`unrepresentable: "any"`.** Required because the option bundles use `z.custom` for clock and signal injection points.

View File

@@ -0,0 +1,37 @@
import { createRegistry } from "@suaveplan/schema-registry";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BITPACKER_GAME_SCHEMA_VERSION,
BITPACKER_GAME_SUBJECT,
registerBitPackerGameSchemas,
} from "./register-schemas.js";
describe("registerBitPackerGameSchemas", () => {
it("registers under the namespaced subject at version 1", () => {
const registry = createRegistry();
registerBitPackerGameSchemas(registry);
expect(registry.getLatest(BITPACKER_GAME_SUBJECT).version).toBe(
BITPACKER_GAME_SCHEMA_VERSION
);
});
it("includes every public schema in the payload", () => {
const registry = createRegistry();
registerBitPackerGameSchemas(registry);
const payload = registry.getLatest(BITPACKER_GAME_SUBJECT)
.schema as Record<string, unknown>;
expect(payload).toHaveProperty("predictionOptions");
expect(payload).toHaveProperty("interpolationOptions");
expect(payload).toHaveProperty("reconciliationOptions");
expect(payload).toHaveProperty("gameStateSchema");
expect(payload).toHaveProperty("quantizedVector");
expect(payload).toHaveProperty("gameBitPackerOptions");
});
it("is idempotent across repeated calls", () => {
const registry = createRegistry();
registerBitPackerGameSchemas(registry);
registerBitPackerGameSchemas(registry);
expect(registry.getLatest(BITPACKER_GAME_SUBJECT).version).toBe(1);
});
});

View File

@@ -0,0 +1,52 @@
import type { SchemaRegistry } from "@suaveplan/schema-registry";
import z from "zod";
import {
gameBitPackerOptionsSchema,
gameStateSchemaSchema,
interpolationOptionsSchema,
predictionOptionsSchema,
quantizedVectorSchema,
reconciliationOptionsSchema,
} from "../types.schema/types.schema.js";
export const BITPACKER_GAME_SUBJECT =
"bitpacker:@suaveplan/bitpacker-game" as const;
export const BITPACKER_GAME_SCHEMA_VERSION = 1;
interface Payload {
readonly predictionOptions: unknown;
readonly interpolationOptions: unknown;
readonly reconciliationOptions: unknown;
readonly gameStateSchema: unknown;
readonly quantizedVector: unknown;
readonly gameBitPackerOptions: unknown;
}
function buildPayload(): Payload {
const opts = { unrepresentable: "any" } as const;
return {
predictionOptions: z.toJSONSchema(predictionOptionsSchema, opts),
interpolationOptions: z.toJSONSchema(interpolationOptionsSchema, opts),
reconciliationOptions: z.toJSONSchema(
reconciliationOptionsSchema,
opts
),
gameStateSchema: z.toJSONSchema(gameStateSchemaSchema, opts),
quantizedVector: z.toJSONSchema(quantizedVectorSchema, opts),
gameBitPackerOptions: z.toJSONSchema(gameBitPackerOptionsSchema, opts),
};
}
/**
* Idempotently register `@suaveplan/bitpacker-game`'s public Zod schemas
* with the given `SchemaRegistry`.
*/
export function registerBitPackerGameSchemas(registry: SchemaRegistry): void {
if (registry.listSubjects().includes(BITPACKER_GAME_SUBJECT)) {
return;
}
registry.register(BITPACKER_GAME_SUBJECT, buildPayload(), "json-schema", {
package: "@suaveplan/bitpacker-game",
namespace: "bitpacker",
});
}

View File

@@ -0,0 +1,68 @@
# Game Schema Factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Programmatic schema construction for game-specific bitpacker schemas. `createGameSchema(options)` returns a tuned `BitPackerSchema` based on the consumer's choice of position bit-width, rotation precision, health/speed caps, and other gameplay-domain knobs. The function lets a consumer derive a schema from per-game design parameters (e.g. "this game has 1024 m world bounds with 0.01 m precision") rather than hand-rolling the same schema across multiple sources.
The `GameSchemaFactory` const is a curated set of named factory presets — `small`, `medium`, `large`, `precise`, `arcade` — each producing a `BitPackerSchema` with appropriate trade-offs. New presets can be appended; the consumer's `createGameSchema(options)` always overrides preset defaults with explicit options.
## Features
- `createGameSchema(options?)` — main factory that returns a fully-tuned `BitPackerSchema`
- `GameSchemaFactory` — const map of named presets (`small`, `medium`, `large`, `precise`, `arcade`)
- `GameSchemaOptions` — `maxPosition`, `positionPrecision`, `maxRotation`, `rotationPrecision`, `maxHealth`, `maxSpeed`, plus optional fields for actions, effects, animations
- All numeric inputs are checked at construction; out-of-range values produce an error rather than a corrupted schema
## Usage: Basic Example
```ts
import { createGameSchema } from "@suaveplan/bitpacker-game";
const schema = createGameSchema({
maxPosition: 512,
positionPrecision: 1,
maxHealth: 100,
});
```
## Usage: Advanced Example
```ts
import {
createGameSchema,
GameBitPacker,
GameSchemaFactory,
} from "@suaveplan/bitpacker-game";
// Pick a preset for arcade-style games (small world, low precision):
const arcade = GameSchemaFactory.arcade;
const arcadeBP = new GameBitPacker(arcade);
// Or compose: start from a preset and override a single knob:
const customLarge = createGameSchema({
...GameSchemaFactory.large,
rotationPrecision: 0.1, // fine yaw for first-person aim
maxSpeed: 20,
});
const customBP = new GameBitPacker(customLarge);
```
## API Reference
### `createGameSchema(options?: GameSchemaOptions): BitPackerSchema`
Builds a `BitPackerSchema` for a player state with position, rotation, stats, and optional metadata. `options` defaults to `{ maxPosition: 1024, positionPrecision: 2, maxRotation: 180, rotationPrecision: 1, maxHealth: 100, maxSpeed: 8 }`. Returns a schema ready to pass to `new GameBitPacker(schema)`.
### `GameSchemaFactory: { small, medium, large, precise, arcade }`
Const map of pre-built schemas. Each preset is a fully-tuned `BitPackerSchema` produced by `createGameSchema` with appropriate options. Consumers use these as starting points or as drop-in choices for typical game shapes.
## Implementation Notes
- **Precision is a divisor, not a digit count.** `positionPrecision: 0.01` means the schema rounds positions to 2-decimal places (1/100). `positionPrecision: 1` means whole units. The fixed-point bit width is computed from `maxPosition / precision`.
- **Rotation is wrapped to `[-180, 180]`.** Values outside this range are treated as out-of-range at pack time; consumers handling continuous rotations must wrap to this range first.
- **Schema shape is stable across presets.** All factory output shares the same top-level keys (`position`, `rotation`, `stats`, `optional`); only the per-field bit-widths differ. This lets a consumer swap presets at runtime without re-deriving downstream code.
- **No defaults in the schema itself.** The output is a pure schema; default values for missing fields are the consumer's responsibility (e.g. via the optional-with-default strategy in `@suaveplan/bitpacker-strategies`).

View File

@@ -0,0 +1,50 @@
/**
* Factory for creating game-specific schemas
*/
import type { BitPackerSchema } from "@suaveplan/types";
import type { GameSchemaOptions } from "./schema-factory.types.js";
/**
* Create a game schema with customizable options
*/
export function createGameSchema(
options: GameSchemaOptions = {}
): BitPackerSchema {
const {
maxPosition = 1024,
positionPrecision = 2,
maxRotation = 180,
rotationPrecision = 1,
maxHealth = 100,
maxSpeed = 8,
speedPrecision = 1,
} = options;
return {
position: {
x: `-${maxPosition}.${positionPrecision}`,
y: `-${maxPosition}.${positionPrecision}`,
z: `-${Math.floor(maxPosition / 8)}.${positionPrecision}`, // z typically has smaller range
},
rotation: {
yaw: `-${maxRotation}.${rotationPrecision}`,
pitch: `-${Math.min(maxRotation, 90)}.${rotationPrecision}`, // Pitch usually limited to -90 to 90
},
stats: {
health: maxHealth,
shield: maxHealth,
speed: `${maxSpeed}.${speedPrecision}`,
isMoving: true,
isSprinting: true,
},
optional: {
animation: "-32", // Variable length string up to 32 chars
effects: [7], // Array of effect IDs
},
};
}
export const GameSchemaFactory = {
createGameSchema,
};

View File

@@ -0,0 +1,19 @@
/**
* Types for Game Schema Factory
*/
import type { BitPackerSchema } from "@suaveplan/types";
export interface GameSchemaOptions {
maxPosition?: number;
positionPrecision?: number;
maxRotation?: number;
rotationPrecision?: number;
maxHealth?: number;
maxSpeed?: number;
speedPrecision?: number;
}
export interface GameSchemaFactoryInterface {
createGameSchema(options?: GameSchemaOptions): BitPackerSchema;
}

View File

@@ -0,0 +1,97 @@
# Pre-tuned Game Schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
A curated set of `BitPackerSchema` constants tuned for common game-state shapes — player position, rotation, stats, optional metadata, world updates, player input — plus the `createDeltaSchema` factory that mirrors any of those into its delta form. Constants here are the right choice when a game's data model fits the upstream conventions (1024 m bounded world, 0.01 m precision, 180° rotation, 0–100 health). Games with unusual ranges should use the `createGameSchema` factory in the `schema-factory` module instead.
The schemas are designed to compose: `PLAYER_STATE_SCHEMA` references `PLAYER_POSITION_SCHEMA`, `PLAYER_ROTATION_SCHEMA`, `PLAYER_STATS_SCHEMA`, and the optional bundle. `WORLD_UPDATE_SCHEMA` references the player schema array. Replacing one constant in a downstream consumer cascades automatically to the consumers of the composed schemas.
## Features
- `PLAYER_POSITION_SCHEMA` — fixed-point `{ x, y, z }` with 0.01 precision and ±1024 m / ±128 m ranges
- `PLAYER_ROTATION_SCHEMA` — fixed-point `{ yaw, pitch }`, ±180° / ±90° respectively
- `PLAYER_STATS_SCHEMA` — `{ health, shield, speed, isMoving, isSprinting }` tuned for a typical FPS
- `PLAYER_OPTIONAL_SCHEMA` — animation name + effect-id array, both optional
- `PLAYER_STATE_SCHEMA` — composes all of the above into one player snapshot
- `WORLD_UPDATE_SCHEMA` — variable-length array of player states + a 16-bit tick
- `PLAYER_INPUT_SCHEMA` — analog move + rotate axes + action-id array
- `createDeltaSchema(base)` — wraps any base schema in a delta envelope (changed-mask + per-field optional)
## Usage: Basic Example
```ts
import { GameBitPacker, PLAYER_STATE_SCHEMA } from "@suaveplan/bitpacker-game";
const bp = new GameBitPacker(PLAYER_STATE_SCHEMA);
const bytes = bp.pack({
position: { x: 100, y: 0, z: 50 },
rotation: { yaw: 90, pitch: 0 },
stats: { health: 95, shield: 50, speed: 5, isMoving: true, isSprinting: false },
});
```
## Usage: Advanced Example
```ts
import {
GameBitPacker,
PLAYER_INPUT_SCHEMA,
WORLD_UPDATE_SCHEMA,
createDeltaSchema,
PLAYER_STATE_SCHEMA,
} from "@suaveplan/bitpacker-game";
// Server emits world snapshots every tick:
const worldBP = new GameBitPacker(WORLD_UPDATE_SCHEMA);
// Client emits inputs at frame rate:
const inputBP = new GameBitPacker(PLAYER_INPUT_SCHEMA);
// Server transmits deltas instead of full snapshots:
const playerDeltaSchema = createDeltaSchema(PLAYER_STATE_SCHEMA);
const deltaBP = new GameBitPacker(playerDeltaSchema);
```
## API Reference
### `PLAYER_POSITION_SCHEMA: PositionSchema`
`{ x: "-1024.2", y: "-1024.2", z: "-128.2" }` — signed fixed-point with 0.01 precision. The `-` prefix marks the field as signed.
### `PLAYER_ROTATION_SCHEMA: RotationSchema`
`{ yaw: "-180.1", pitch: "-90.1" }` — signed fixed-point with 0.1 precision.
### `PLAYER_STATS_SCHEMA: PlayerStatsSchema`
`{ health: 100, shield: 100, speed: "8.2", isMoving: true, isSprinting: true }` — uint, uint, fixed-point, bool, bool.
### `PLAYER_OPTIONAL_SCHEMA: PlayerOptionalSchema`
Optional `{ animation, effects }` bundle. Animation is a variable-length string; effects is a variable-length array of compact ids.
### `PLAYER_STATE_SCHEMA: PlayerStateSchema`
Composes `position`, `rotation`, `stats`, `optional`. The canonical shape every game subsystem reads from.
### `WORLD_UPDATE_SCHEMA: WorldUpdateSchema`
`{ tick: 65535, players: [PLAYER_STATE_SCHEMA] }` — one tick + a variable-length array of player states.
### `PLAYER_INPUT_SCHEMA: PlayerInputSchema`
`{ moveX, moveY, rotateYaw, rotatePitch, actions }` — four signed fixed-point axes plus an action-id array.
### `createDeltaSchema<T>(base: T): DeltaSchema<T>`
Returns a schema that wraps `base` with a per-field changed-mask and optional fields. The wrapped schema is appropriate for incremental updates where most fields stay the same frame-to-frame.
## Implementation Notes
- **Bit-widths are tuned for typical FPS games.** ±1024 m world with 0.01 m precision uses ~17 bits per axis after sign + integer + fractional. Smaller worlds save bits per field; the consumer should switch to `createGameSchema` if the defaults are wasteful.
- **`isMoving` + `isSprinting` are independent.** The schema models them as two booleans because a player can be sprinting while not moving (e.g. holding sprint while stuck against a wall) — collapsing to a single tri-state would lose that distinction.
- **`createDeltaSchema` is a factory, not a const.** It returns a fresh schema every call so the consumer can apply additional transformations (e.g. swapping in a custom `_optional` strategy) without mutating shared state.
- **No timestamps in the player schema.** Timing is owned by the `tick` field on `WORLD_UPDATE_SCHEMA`. Consumers that want per-player timestamps must add a `_optional` field at their own layer.

View File

@@ -0,0 +1,92 @@
import type { BitPackerSchema } from "@suaveplan/types";
import type {
DeltaSchema,
DeltaSchemaFactory,
PlayerInputSchema,
PlayerOptionalSchema,
PlayerStateSchema,
PlayerStatsSchema,
PositionSchema,
RotationSchema,
WorldUpdateSchema,
} from "./schemas.types.js";
// Example optimized schema for player position updates
// Uses fixed point numbers with appropriate ranges for typical game coordinates
// x,y: -1024 to 1024 with 0.01 precision (2 decimal places)
// z: -128 to 128 with 0.01 precision (2 decimal places)
export const PLAYER_POSITION_SCHEMA: PositionSchema = {
x: "-1024.2",
y: "-1024.2",
z: "-128.2",
};
// Example schema for player rotation
export const PLAYER_ROTATION_SCHEMA: RotationSchema = {
// Store rotation as -180 to 180 degrees with 1 decimal precision
yaw: "-180.1",
pitch: "-90.1", // Typically -90 to 90 is sufficient for pitch
};
// Example schema for player stats
export const PLAYER_STATS_SCHEMA: PlayerStatsSchema = {
health: 100, // 0-100 range
shield: 100, // 0-100 range
speed: "-8.1", // 0-8 range with 1 decimal
isMoving: true, // Boolean flag for movement state
isSprinting: true, // Boolean flag for sprint state
};
// Example schema for optional fields
export const PLAYER_OPTIONAL_SCHEMA: PlayerOptionalSchema = {
animation: "-32", // Variable length string up to 32 chars for animation state
effects: [7], // Array of effect IDs (0-7 range)
};
// Example schema for player state updates
export const PLAYER_STATE_SCHEMA: PlayerStateSchema = {
position: PLAYER_POSITION_SCHEMA,
rotation: PLAYER_ROTATION_SCHEMA,
stats: PLAYER_STATS_SCHEMA,
optional: PLAYER_OPTIONAL_SCHEMA,
};
// Example schema for a game world update packet
export const WORLD_UPDATE_SCHEMA: WorldUpdateSchema = {
tick: 2147483647, // Game tick/frame number (32-bit max)
players: [PLAYER_STATE_SCHEMA], // Array of player states
// Add other world state data as needed
};
// Example schema for player input packet
export const PLAYER_INPUT_SCHEMA: PlayerInputSchema = {
tick: 2147483647,
input: {
movement: {
forward: true,
backward: true,
left: true,
right: true,
jump: true,
},
actions: {
primary: true,
secondary: true,
reload: true,
},
},
};
// Helper function to create a delta-optimized schema
export const createDeltaSchema: DeltaSchemaFactory = <
T extends BitPackerSchema,
>(
baseSchema: T
): DeltaSchema<T> => {
return {
_bp: {
schema: baseSchema,
value: undefined,
},
};
};

View File

@@ -0,0 +1,85 @@
/**
* Types for Game Schemas
*/
import type { BitPackerSchema } from "@suaveplan/types";
// Position schema type
export interface PositionSchema {
x: "-1024.2";
y: "-1024.2";
z: "-128.2";
}
// Rotation schema type
export interface RotationSchema {
yaw: "-180.1";
pitch: "-90.1";
}
// Player stats schema type
export interface PlayerStatsSchema {
health: number;
shield: number;
speed: "-8.1";
isMoving: boolean;
isSprinting: boolean;
}
// Optional fields schema type
export interface PlayerOptionalSchema {
animation: "-32";
effects: number[];
}
// Player state schema type
export interface PlayerStateSchema {
position: PositionSchema;
rotation: RotationSchema;
stats: PlayerStatsSchema;
optional: PlayerOptionalSchema;
}
// World update schema type
export interface WorldUpdateSchema {
tick: number;
players: PlayerStateSchema[];
}
// Player input movement schema
export interface PlayerInputMovementSchema {
forward: boolean;
backward: boolean;
left: boolean;
right: boolean;
jump: boolean;
}
// Player input actions schema
export interface PlayerInputActionsSchema {
primary: boolean;
secondary: boolean;
reload: boolean;
}
// Player input schema type
export interface PlayerInputSchema {
tick: number;
input: {
movement: PlayerInputMovementSchema;
actions: PlayerInputActionsSchema;
};
}
// Delta schema type
export interface DeltaSchema<T extends BitPackerSchema> {
_bp: {
schema: T;
value: undefined;
};
}
// Schema factory function type
export type DeltaSchemaFactory = <T extends BitPackerSchema>(
baseSchema: T
) => DeltaSchema<T>;

View File

@@ -0,0 +1,85 @@
# GameStateManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
State ownership and delta tracking for the `GameBitPacker`. `GameStateManager<T>` holds the current authoritative state, the last-sent state (for delta computation), and a 4-bit rolling sequence number used to identify out-of-order or duplicate updates on the wire. The class is generic on the `BitPackerSchema` to keep the held state type-safe; the implementation stores the value as `unknown` internally because schema-driven serialization erases the static type at the boundary.
The 4-bit sequence is the key wire-format constraint: the upstream `bitpacker-game` reserves four bits per state update to discriminate which update a delta refers to. `MAX_SEQUENCE = 15` is exposed as a `static readonly` field so callers can validate sequence wraps without redeclaring the constant.
## Features
- `updateState` — replace the current authoritative state
- `getLatestState` / `getLastSentState` — accessors for the live state and the last-transmitted state
- `markSent` — record that the current state has been transmitted (advances the rolling sequence)
- `processReceived` — handle an incoming update, gate against `lastReceivedSequence` to drop replays
- `computeDelta` — produce a `StateDelta<T>` describing the change from `lastSentState` to `currentState`
- `MAX_SEQUENCE` — the 4-bit wire-format cap (`15`)
## Usage: Basic Example
```ts
import { GameStateManager } from "@suaveplan/bitpacker-game";
const manager = new GameStateManager();
manager.updateState({ x: 10, y: 20 });
const latest = manager.getLatestState();
```
## Usage: Advanced Example
```ts
import { GameStateManager } from "@suaveplan/bitpacker-game";
const manager = new GameStateManager<{ position: { x: number; y: number } }>();
function tick(serverFrame: { position: { x: number; y: number } }, sequence: number) {
if (manager.processReceived(serverFrame, sequence)) {
// Accepted as in-order
const delta = manager.computeDelta();
if (delta.hasChanges) {
// …transmit delta only…
manager.markSent();
}
}
}
```
## API Reference
### `class GameStateManager<T extends BitPackerSchema> implements GameStateManagerInterface<T>`
Stores the latest state, the last-sent state, and a rolling 4-bit sequence. The class is generic so consumers can preserve type information through `getLatestState`.
#### `static readonly MAX_SEQUENCE = 15`
The wire-format cap on the rolling sequence. Sequence wraps from `15` back to `0`.
#### `updateState(state: unknown): void`
Replaces the current state. Does NOT advance the sequence — sequence advances only on `markSent`.
#### `getLatestState(): unknown | undefined`
Returns the current authoritative state, or `undefined` if no state has been pushed yet.
#### `markSent(): void`
Snapshots `currentState` into `lastSentState` and advances `sequence` modulo `MAX_SEQUENCE + 1`.
#### `processReceived(state: unknown, sequence: number): boolean`
Returns `true` if the incoming update is in-order (sequence ≥ `lastReceivedSequence`); `false` if it's a replay or out-of-order. Gates downstream consumers from acting on stale frames.
#### `computeDelta<T>(): StateDelta<T>`
Returns a `StateDelta<T>` with `hasChanges`, `delta` (partial of T), and `changedPaths` (string array). Compares `currentState` against `lastSentState` field-by-field.
## Implementation Notes
- **Sequence wrap is modulo 16.** The 4-bit constraint makes the sequence wrap from `15` to `0` after `markSent`. Receivers that compare sequence numbers must use modular comparison (e.g. `((received - lastReceived + 16) % 16) < 8`) rather than naive `received > lastReceived`.
- **`unknown`-typed internals.** `currentState` and `lastSentState` are typed `unknown` rather than `T` because the schema-driven pack/unpack erases the static type at the wire boundary. Generic callers preserve type-safety at their own call sites via the `T extends BitPackerSchema` parameter.
- **No deep clone on `markSent`.** The snapshot is a structural reference, not a deep clone. Consumers mutating `currentState` after `markSent` must do so via copy-on-write semantics or risk corrupting the prior delta baseline.
- **`processReceived` is a gate, not a writer.** It returns a boolean so the caller decides what to do with rejected frames (drop, log, request resync). The function does not auto-advance `lastReceivedSequence` on rejection.

View File

@@ -0,0 +1,209 @@
/**
* State management functionality for GameBitPacker
*/
import type { BitPackerSchema } from "@suaveplan/types";
import type { StateDelta } from "../types.js";
import type { GameStateManagerInterface } from "./state-manager.types.js";
export class GameStateManager<T extends BitPackerSchema>
implements GameStateManagerInterface<T>
{
private currentState: unknown | undefined;
private lastSentState: unknown | undefined;
private sequence = 0;
private lastReceivedSequence = -1;
public static readonly MAX_SEQUENCE = 15; // 4 bits for sequence
/**
* Update the current state with new data
*/
public update(data: unknown, isInitialState = false): void {
this.currentState = this.deepClone(data);
if (isInitialState) {
this.lastSentState = this.deepClone(data);
this.sequence = 0;
this.lastReceivedSequence = 0; // Start at 0 to receive sequence 1
} else {
this.sequence = (this.sequence + 1) % GameStateManager.MAX_SEQUENCE;
}
}
/**
* Get the current state
*/
public getState(): unknown | undefined {
return this.currentState
? this.deepClone(this.currentState)
: undefined;
}
/**
* Get the last sent state
*/
public getLastSentState(): unknown | undefined {
return this.lastSentState
? this.deepClone(this.lastSentState)
: undefined;
}
/**
* Update last sent state
*/
public updateLastSentState(state: unknown): void {
this.lastSentState = this.deepClone(state);
}
/**
* Get current sequence number
*/
public getSequence(): number {
return this.sequence;
}
/**
* Get last received sequence number
*/
public getLastReceivedSequence(): number {
return this.lastReceivedSequence;
}
/**
* Update last received sequence
*/
public updateLastReceivedSequence(sequence: number): void {
this.lastReceivedSequence = sequence;
}
/**
* Reset state
*/
public reset(): void {
this.currentState = undefined;
this.lastSentState = undefined;
this.sequence = 0;
this.lastReceivedSequence = -1;
}
/**
* Check if initialized
*/
public isInitialized(): boolean {
return this.currentState !== undefined;
}
/**
* Compute delta between two states
*/
public computeDelta(
current: unknown,
previous: unknown
): StateDelta<unknown> {
const delta: Record<string, unknown> = {};
const changedPaths: string[] = [];
let hasChanges = false;
const compareAndAdd = (curr: unknown, prev: unknown, path: string) => {
if (curr !== prev) {
hasChanges = true;
changedPaths.push(path);
this.setNestedValue(delta, path, curr);
}
};
this.traverseObject(current, previous, "", compareAndAdd);
return { hasChanges, delta, changedPaths };
}
/**
* Deep clone an object
*/
private deepClone<U>(obj: U): U {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as unknown as U;
if (Array.isArray(obj)) {
const clonedArr: unknown[] = [];
for (const item of obj) {
clonedArr.push(this.deepClone(item));
}
return clonedArr as unknown as U;
}
if (obj instanceof Object) {
const clonedObj: Record<string, unknown> = {};
for (const key in obj) {
if (Object.hasOwn(obj, key)) {
clonedObj[key] = this.deepClone(obj[key]);
}
}
return clonedObj as U;
}
return obj;
}
/**
* Traverse object recursively
*/
private traverseObject(
current: unknown,
previous: unknown,
path: string,
callback: (curr: unknown, prev: unknown, path: string) => void
): void {
if (typeof current !== "object" || current === null) {
callback(current, previous, path);
return;
}
if (Array.isArray(current)) {
const prevArray = Array.isArray(previous) ? previous : [];
for (let i = 0; i < current.length; i++) {
this.traverseObject(
current[i],
prevArray[i],
path ? `${path}[${i}]` : `[${i}]`,
callback
);
}
} else {
const prevObj =
typeof previous === "object" && previous !== null
? previous
: {};
for (const key in current) {
if (Object.hasOwn(current, key)) {
this.traverseObject(
(current as Record<string, unknown>)[key],
(prevObj as Record<string, unknown>)[key],
path ? `${path}.${key}` : key,
callback
);
}
}
}
}
/**
* Set nested value in object using path
*/
private setNestedValue(obj: unknown, path: string, value: unknown): void {
const keys = path.split(/\.|\[|\]/).filter((k) => k);
let current = obj as Record<string, unknown>;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!key) continue;
if (!(key in current)) {
const nextKey = keys[i + 1];
current[key] = nextKey && /^\d+$/.test(nextKey) ? [] : {};
}
current = current[key] as Record<string, unknown>;
}
const lastKey = keys[keys.length - 1];
if (lastKey) {
current[lastKey] = value;
}
}
}

View File

@@ -0,0 +1,19 @@
/**
* Types for Game State Manager
*/
import type { BitPackerSchema } from "@suaveplan/types";
import type { StateDelta } from "../types.js";
export interface GameStateManagerInterface<_T extends BitPackerSchema> {
update(data: unknown, isInitialState?: boolean): void;
getState(): unknown | undefined;
getLastSentState(): unknown | undefined;
updateLastSentState(state: unknown): void;
getSequence(): number;
getLastReceivedSequence(): number;
updateLastReceivedSequence(sequence: number): void;
reset(): void;
isInitialized(): boolean;
computeDelta(current: unknown, previous: unknown): StateDelta<unknown>;
}

View File

@@ -0,0 +1,809 @@
import { describe, expect, test } from "@suaveplan/testing/runner";
import { GameInterpolator } from "../index.js";
import type { Vector3 } from "../types.js";
import { InterpolationMethodEnum } from "../types.js";
describe("GameInterpolator", () => {
test("constructor with default options", () => {
const interp = new GameInterpolator();
expect(interp.getOptions().method).toBe(InterpolationMethodEnum.LINEAR);
});
test("constructor with custom options", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.CUBIC,
tension: 0.8,
});
expect(interp.getOptions().method).toBe(InterpolationMethodEnum.CUBIC);
expect(interp.getOptions().tension).toBe(0.8);
});
test("setOptions merges options", () => {
const interp = new GameInterpolator();
interp.setOptions({ damping: 0.5 });
expect(interp.getOptions().damping).toBe(0.5);
expect(interp.getOptions().method).toBe(InterpolationMethodEnum.LINEAR);
});
test("interpolatePosition linear", () => {
const interp = new GameInterpolator();
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 20, z: 30 },
0.5
);
expect(result.x).toBe(5);
expect(result.y).toBe(10);
expect(result.z).toBe(15);
});
test("interpolatePosition cubic", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.CUBIC,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(result.x).toBe(5);
});
test("interpolatePosition hermite", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.HERMITE,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition hermite with custom tension and bias", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.HERMITE,
tension: 0.8,
bias: 0.2,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition hermite with falsy tension and bias", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.HERMITE,
tension: 0,
bias: 0,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition catmullRom", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.CATMULL_ROM,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition catmullRom with custom tension", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.CATMULL_ROM,
tension: 0.8,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition catmullRom with falsy tension", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.CATMULL_ROM,
tension: 0,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition bezier", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.BEZIER,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition spring with zeta < 1", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.SPRING,
springConstant: 0.3,
damping: 0.5,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition spring with default options", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.SPRING,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition spring with falsy springConstant and damping", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.SPRING,
springConstant: 0,
damping: 0,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition spring with zeta === 1", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.SPRING,
springConstant: 0.3,
damping: 1,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition spring with zeta > 1", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.SPRING,
springConstant: 0.3,
damping: 1.5,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition elastic at t=0", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.ELASTIC,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0
);
expect(result.x).toBe(0);
});
test("interpolatePosition elastic at t=1", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.ELASTIC,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
1
);
expect(result.x).toBe(10);
});
test("interpolatePosition elastic normal", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.ELASTIC,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition elastic with custom damping", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.ELASTIC,
damping: 0.5,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition elastic with falsy damping", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.ELASTIC,
damping: 0,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition bounce at t=0", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.BOUNCE,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0
);
expect(result.x).toBe(0);
});
test("interpolatePosition bounce at t=1", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.BOUNCE,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
1
);
expect(result.x).toBe(10);
});
test("interpolatePosition bounce branch t < 4/11", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.BOUNCE,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.3
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition bounce branch t < 8/11", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.BOUNCE,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.6
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition bounce branch t < 10/11", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.BOUNCE,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.85
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition bounce branch t >= 10/11", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.BOUNCE,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.95
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition smoothstep", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.SMOOTHSTEP,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5
);
expect(typeof result.x).toBe("number");
});
test("interpolatePosition default fallback", () => {
const interp = new GameInterpolator();
// @ts-expect-error testing invalid method
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5,
"unknown"
);
expect(result.x).toBe(5);
});
test("interpolatePosition with explicit method overrides options", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.LINEAR,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5,
InterpolationMethodEnum.CUBIC
);
expect(result.x).toBe(5);
});
test("interpolatePosition with undefined method and undefined options method", () => {
const interp = new GameInterpolator({
method: undefined as unknown as InterpolationMethodEnum,
});
const result = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
0.5,
undefined
);
expect(result.x).toBe(5);
});
test("interpolateRotation default linear", () => {
const interp = new GameInterpolator();
const result = interp.interpolateRotation(
{ yaw: 0, pitch: 0 },
{ yaw: 90, pitch: 45 },
0.5
);
expect(result.yaw).toBe(45);
expect(result.pitch).toBe(22.5);
});
test("interpolateRotation with explicit undefined method", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.CUBIC,
});
const result = interp.interpolateRotation(
{ yaw: 0, pitch: 0 },
{ yaw: 90, pitch: 0 },
0.5,
undefined
);
expect(typeof result.yaw).toBe("number");
});
test("interpolateRotation with explicit smoothstep method", () => {
const interp = new GameInterpolator();
const result = interp.interpolateRotation(
{ yaw: 0, pitch: 0 },
{ yaw: 90, pitch: 0 },
0.5,
InterpolationMethodEnum.SMOOTHSTEP
);
expect(typeof result.yaw).toBe("number");
});
test("interpolateRotation with undefined method and undefined options method", () => {
const interp = new GameInterpolator({
method: undefined as unknown as InterpolationMethodEnum,
});
const result = interp.interpolateRotation(
{ yaw: 0, pitch: 0 },
{ yaw: 90, pitch: 0 },
0.5,
undefined
);
expect(typeof result.yaw).toBe("number");
});
test("interpolateRotation with angle wrapping (ns.yaw < ne.yaw false)", () => {
const interp = new GameInterpolator();
const result = interp.interpolateRotation(
{ yaw: 170, pitch: 0 },
{ yaw: -170, pitch: 0 },
0.5
);
expect(Math.abs(result.yaw)).toBeLessThanOrEqual(180);
});
test("interpolateRotation with angle wrapping (ns.yaw < ne.yaw true)", () => {
const interp = new GameInterpolator();
const result = interp.interpolateRotation(
{ yaw: -170, pitch: 0 },
{ yaw: 170, pitch: 0 },
0.5
);
expect(Math.abs(result.yaw)).toBeLessThanOrEqual(180);
});
test("interpolateRotation cubic", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.CUBIC,
});
const result = interp.interpolateRotation(
{ yaw: 0, pitch: 0 },
{ yaw: 90, pitch: 0 },
0.5
);
expect(typeof result.yaw).toBe("number");
});
test("interpolateRotation smoothstep", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.SMOOTHSTEP,
});
const result = interp.interpolateRotation(
{ yaw: 0, pitch: 0 },
{ yaw: 90, pitch: 0 },
0.5
);
expect(typeof result.yaw).toBe("number");
});
test("interpolateRotation clamps pitch", () => {
const interp = new GameInterpolator();
const result = interp.interpolateRotation(
{ yaw: 0, pitch: -100 },
{ yaw: 0, pitch: 100 },
0.5
);
expect(result.pitch).toBe(0);
});
test("interpolatePlayerState without optional fields", () => {
const interp = new GameInterpolator();
const start = {
position: { x: 0, y: 0, z: 0 },
rotation: { yaw: 0, pitch: 0 },
stats: {
health: 100,
shield: 50,
speed: 5,
isMoving: false,
isSprinting: false,
},
};
const end = {
position: { x: 10, y: 0, z: 0 },
rotation: { yaw: 90, pitch: 0 },
stats: {
health: 80,
shield: 40,
speed: 10,
isMoving: true,
isSprinting: true,
},
};
const result = interp.interpolatePlayerState(start, end, 0.5);
expect(result.position.x).toBe(5);
expect(result.stats.health).toBe(90);
expect(result.stats.isMoving).toBe(false);
expect(result.stats.isSprinting).toBe(false);
});
test("interpolatePlayerState t > 0.5 uses end booleans", () => {
const interp = new GameInterpolator();
const start = {
position: { x: 0, y: 0, z: 0 },
rotation: { yaw: 0, pitch: 0 },
stats: {
health: 100,
shield: 50,
speed: 5,
isMoving: false,
isSprinting: false,
},
};
const end = {
position: { x: 10, y: 0, z: 0 },
rotation: { yaw: 90, pitch: 0 },
stats: {
health: 80,
shield: 40,
speed: 10,
isMoving: true,
isSprinting: true,
},
};
const result = interp.interpolatePlayerState(start, end, 0.6);
expect(result.stats.isMoving).toBe(true);
expect(result.stats.isSprinting).toBe(true);
});
test("interpolatePlayerState with optional animation from end", () => {
const interp = new GameInterpolator();
const start = {
position: { x: 0, y: 0, z: 0 },
rotation: { yaw: 0, pitch: 0 },
stats: {
health: 100,
shield: 50,
speed: 5,
isMoving: false,
isSprinting: false,
},
};
const end = {
position: { x: 10, y: 0, z: 0 },
rotation: { yaw: 90, pitch: 0 },
stats: {
health: 80,
shield: 40,
speed: 10,
isMoving: true,
isSprinting: true,
},
optional: { animation: "run" },
};
const result = interp.interpolatePlayerState(start, end, 0.5);
expect(result.optional?.animation).toBe("run");
});
test("interpolatePlayerState with optional animation from start", () => {
const interp = new GameInterpolator();
const start = {
position: { x: 0, y: 0, z: 0 },
rotation: { yaw: 0, pitch: 0 },
stats: {
health: 100,
shield: 50,
speed: 5,
isMoving: false,
isSprinting: false,
},
optional: { animation: "walk" },
};
const end = {
position: { x: 10, y: 0, z: 0 },
rotation: { yaw: 90, pitch: 0 },
stats: {
health: 80,
shield: 40,
speed: 10,
isMoving: true,
isSprinting: true,
},
};
const result = interp.interpolatePlayerState(start, end, 0.5);
expect(result.optional?.animation).toBe("walk");
});
test("interpolatePlayerState merges effects", () => {
const interp = new GameInterpolator();
const start = {
position: { x: 0, y: 0, z: 0 },
rotation: { yaw: 0, pitch: 0 },
stats: {
health: 100,
shield: 50,
speed: 5,
isMoving: false,
isSprinting: false,
},
optional: { effects: ["a", "b"] },
};
const end = {
position: { x: 10, y: 0, z: 0 },
rotation: { yaw: 90, pitch: 0 },
stats: {
health: 80,
shield: 40,
speed: 10,
isMoving: true,
isSprinting: true,
},
optional: { effects: ["b", "c"] },
};
const result = interp.interpolatePlayerState(start, end, 0.5);
expect(result.optional?.effects).toContain("a");
expect(result.optional?.effects).toContain("b");
expect(result.optional?.effects).toContain("c");
});
test("interpolatePlayerState with only start effects", () => {
const interp = new GameInterpolator();
const start = {
position: { x: 0, y: 0, z: 0 },
rotation: { yaw: 0, pitch: 0 },
stats: {
health: 100,
shield: 50,
speed: 5,
isMoving: false,
isSprinting: false,
},
optional: { effects: ["a"] },
};
const end = {
position: { x: 10, y: 0, z: 0 },
rotation: { yaw: 90, pitch: 0 },
stats: {
health: 80,
shield: 40,
speed: 10,
isMoving: true,
isSprinting: true,
},
};
const result = interp.interpolatePlayerState(start, end, 0.5);
expect(result.optional?.effects).toEqual(["a"]);
});
test("interpolatePlayerState with only end effects", () => {
const interp = new GameInterpolator();
const start = {
position: { x: 0, y: 0, z: 0 },
rotation: { yaw: 0, pitch: 0 },
stats: {
health: 100,
shield: 50,
speed: 5,
isMoving: false,
isSprinting: false,
},
};
const end = {
position: { x: 10, y: 0, z: 0 },
rotation: { yaw: 90, pitch: 0 },
stats: {
health: 80,
shield: 40,
speed: 10,
isMoving: true,
isSprinting: true,
},
optional: { effects: ["b"] },
};
const result = interp.interpolatePlayerState(start, end, 0.5);
expect(result.optional?.effects).toEqual(["b"]);
});
test("interpolatePath with empty array", () => {
const interp = new GameInterpolator();
const result = interp.interpolatePath([], 0.5);
expect(result).toEqual({ x: 0, y: 0, z: 0 });
});
test("interpolatePath with single point", () => {
const interp = new GameInterpolator();
const result = interp.interpolatePath([{ x: 5, y: 10, z: 15 }], 0.5);
expect(result).toEqual({ x: 5, y: 10, z: 15 });
});
test("interpolatePath with single point undefined coords", () => {
const interp = new GameInterpolator();
const result = interp.interpolatePath(
[
{
x: undefined as unknown as number,
y: undefined as unknown as number,
z: undefined as unknown as number,
},
],
0.5
);
expect(result).toEqual({ x: 0, y: 0, z: 0 });
});
test("interpolatePath with undefined point", () => {
const interp = new GameInterpolator();
const result = interp.interpolatePath(
[undefined as unknown as Vector3],
0.5
);
expect(result).toEqual({ x: 0, y: 0, z: 0 });
});
test("interpolatePath with two points", () => {
const interp = new GameInterpolator();
const result = interp.interpolatePath(
[
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 20, z: 30 },
],
0.5
);
expect(result.x).toBe(5);
expect(result.y).toBe(10);
expect(result.z).toBe(15);
});
test("interpolatePath with three points", () => {
const interp = new GameInterpolator();
const points = [
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
{ x: 20, y: 0, z: 0 },
];
const result = interp.interpolatePath(points, 0.25);
expect(result.x).toBe(5);
});
test("interpolatePath with t at boundary", () => {
const interp = new GameInterpolator();
const points = [
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
{ x: 20, y: 0, z: 0 },
];
const result = interp.interpolatePath(points, 1);
expect(result.x).toBe(20);
});
test("interpolatePath with explicit method", () => {
const interp = new GameInterpolator();
const points = [
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
];
const result = interp.interpolatePath(
points,
0.5,
InterpolationMethodEnum.CUBIC
);
expect(result.x).toBe(5);
});
test("smoothstep edge cases", () => {
const interp = new GameInterpolator({
method: InterpolationMethodEnum.SMOOTHSTEP,
});
// t < 0 should clamp to 0
const r1 = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
-1
);
expect(r1.x).toBe(0);
// t > 1 should clamp to 1
const r2 = interp.interpolatePosition(
{ x: 0, y: 0, z: 0 },
{ x: 10, y: 0, z: 0 },
2
);
expect(r2.x).toBe(10);
});
});

View File

@@ -0,0 +1,874 @@
import { describe, expect, test } from "@suaveplan/testing/runner";
import {
calculateAcceleration,
calculateAccelerationAt,
calculateJerk,
calculateVelocity,
calculateVelocityAt,
catmullRomPredict,
cubicPredict,
hermitePredict,
initKalmanState,
kalmanPredict,
linearPredict,
PositionPredictor,
PredictionAlgorithm,
quadraticPredict,
RotationPredictor,
updateKalmanFilter,
verletPredict,
} from "../index.js";
import type { HistoryPoint, Vector3 } from "../types.js";
describe("kalman-filter", () => {
test("initKalmanState returns default state", () => {
const state = initKalmanState();
expect(state.position).toEqual({ x: 0, y: 0, z: 0 });
expect(state.velocity).toEqual({ x: 0, y: 0, z: 0 });
expect(state.positionCovariance).toBe(1.0);
expect(state.velocityCovariance).toBe(1.0);
expect(state.processNoise).toBe(0.01);
expect(state.measurementNoise).toBe(0.1);
});
test("updateKalmanFilter with dt <= 0 returns state unchanged", () => {
const state = initKalmanState();
const history: HistoryPoint<Vector3>[] = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 1000 },
{ value: { x: 1, y: 0, z: 0 }, timestamp: 1000 },
];
const result = updateKalmanFilter(state, { x: 1, y: 0, z: 0 }, history);
expect(result).toBe(state);
});
test("updateKalmanFilter with history length < 2", () => {
const state = initKalmanState();
const history: HistoryPoint<Vector3>[] = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
];
const result = updateKalmanFilter(state, { x: 1, y: 0, z: 0 }, history);
// dt=0 returns state unchanged
expect(result).toBe(state);
});
test("updateKalmanFilter with history length >= 2 updates velocity", () => {
const state = initKalmanState();
const history: HistoryPoint<Vector3>[] = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 1, y: 0, z: 0 }, timestamp: 1000 },
];
const result = updateKalmanFilter(state, { x: 1, y: 0, z: 0 }, history);
expect(result.position.x).toBeGreaterThan(0);
expect(result.velocity.x).toBeGreaterThan(0);
});
test("updateKalmanFilter handles sparse array via dt <= 0 (prev undefined)", () => {
const state = initKalmanState();
const history: HistoryPoint<Vector3>[] = [];
history.length = 2;
history[1] = { value: { x: 1, y: 0, z: 0 }, timestamp: 1000 };
const result = updateKalmanFilter(state, { x: 1, y: 0, z: 0 }, history);
expect(result).toBe(state);
});
test("updateKalmanFilter handles sparse array via dt <= 0 (last undefined)", () => {
const state = initKalmanState();
const history: HistoryPoint<Vector3>[] = [];
history.length = 2;
history[0] = { value: { x: 0, y: 0, z: 0 }, timestamp: 0 };
const result = updateKalmanFilter(state, { x: 1, y: 0, z: 0 }, history);
expect(result).toBe(state);
});
test("kalmanPredict with null state uses fallback", () => {
const fallback = () => ({ x: 99, y: 0, z: 0 });
const result = kalmanPredict(null, 1, fallback);
expect(result).toEqual({ x: 99, y: 0, z: 0 });
});
test("kalmanPredict with state predicts position", () => {
const state = initKalmanState();
state.velocity = { x: 1, y: 2, z: 3 };
const fallback = () => ({ x: 0, y: 0, z: 0 });
const result = kalmanPredict(state, 2, fallback);
expect(result.x).toBe(2);
expect(result.y).toBe(4);
expect(result.z).toBe(6);
});
});
describe("motion-math", () => {
test("calculateVelocity with < 2 points", () => {
expect(calculateVelocity([])).toEqual({ x: 0, y: 0, z: 0 });
expect(
calculateVelocity([{ value: { x: 1, y: 0, z: 0 }, timestamp: 0 }])
).toEqual({ x: 0, y: 0, z: 0 });
});
test("calculateVelocity with dt <= 0", () => {
const history = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 1000 },
{ value: { x: 10, y: 0, z: 0 }, timestamp: 1000 },
];
expect(calculateVelocity(history)).toEqual({ x: 0, y: 0, z: 0 });
});
test("calculateVelocity normal", () => {
const history = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 10, y: 20, z: 30 }, timestamp: 1000 },
];
const result = calculateVelocity(history);
expect(result.x).toBe(10);
expect(result.y).toBe(20);
expect(result.z).toBe(30);
});
test("calculateVelocity with sparse array", () => {
const history: HistoryPoint<Vector3>[] = [];
history.length = 2;
history[1] = { value: { x: 1, y: 0, z: 0 }, timestamp: 1000 };
expect(calculateVelocity(history)).toEqual({ x: 0, y: 0, z: 0 });
});
test("calculateVelocityAt index < 1", () => {
const history = [{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 }];
expect(calculateVelocityAt(history, 0)).toEqual({ x: 0, y: 0, z: 0 });
});
test("calculateVelocityAt index >= length", () => {
const history = [{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 }];
expect(calculateVelocityAt(history, 5)).toEqual({ x: 0, y: 0, z: 0 });
});
test("calculateVelocityAt dt <= 0", () => {
const history = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 10, y: 0, z: 0 }, timestamp: 0 },
];
expect(calculateVelocityAt(history, 1)).toEqual({ x: 0, y: 0, z: 0 });
});
test("calculateVelocityAt normal", () => {
const history = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 10, y: 20, z: 30 }, timestamp: 1000 },
];
const result = calculateVelocityAt(history, 1);
expect(result.x).toBe(10);
});
test("calculateVelocityAt with sparse array", () => {
const history: HistoryPoint<Vector3>[] = [];
history.length = 2;
history[1] = { value: { x: 1, y: 0, z: 0 }, timestamp: 1000 };
expect(calculateVelocityAt(history, 1)).toEqual({ x: 0, y: 0, z: 0 });
});
test("calculateAcceleration with < 3 points", () => {
expect(calculateAcceleration([])).toEqual({ x: 0, y: 0, z: 0 });
expect(
calculateAcceleration([
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 10, y: 0, z: 0 }, timestamp: 1000 },
])
).toEqual({ x: 0, y: 0, z: 0 });
});
test("calculateAcceleration normal", () => {
const history = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 5, y: 0, z: 0 }, timestamp: 1000 },
{ value: { x: 20, y: 0, z: 0 }, timestamp: 2000 },
];
const result = calculateAcceleration(history);
expect(typeof result.x).toBe("number");
});
test("calculateAcceleration with dt <= 0", () => {
const history = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 5, y: 0, z: 0 }, timestamp: 1000 },
{ value: { x: 20, y: 0, z: 0 }, timestamp: 1000 },
];
expect(calculateAcceleration(history)).toEqual({ x: 0, y: 0, z: 0 });
});
test("calculateAcceleration with sparse array", () => {
const history: HistoryPoint<Vector3>[] = [];
history.length = 3;
history[2] = { value: { x: 1, y: 0, z: 0 }, timestamp: 1000 };
expect(calculateAcceleration(history)).toEqual({ x: 0, y: 0, z: 0 });
});
test("calculateAccelerationAt index < 2", () => {
const history = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 10, y: 0, z: 0 }, timestamp: 1000 },
];
expect(calculateAccelerationAt(history, 0)).toEqual({
x: 0,
y: 0,
z: 0,
});
});
test("calculateAccelerationAt index >= length", () => {
const history = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 10, y: 0, z: 0 }, timestamp: 1000 },
];
expect(calculateAccelerationAt(history, 5)).toEqual({
x: 0,
y: 0,
z: 0,
});
});
test("calculateAccelerationAt normal", () => {
const history = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 5, y: 0, z: 0 }, timestamp: 1000 },
{ value: { x: 20, y: 0, z: 0 }, timestamp: 2000 },
];
const result = calculateAccelerationAt(history, 2);
expect(typeof result.x).toBe("number");
});
test("calculateAccelerationAt dt <= 0", () => {
const history = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 5, y: 0, z: 0 }, timestamp: 1000 },
{ value: { x: 20, y: 0, z: 0 }, timestamp: 1000 },
];
expect(calculateAccelerationAt(history, 2)).toEqual({
x: 0,
y: 0,
z: 0,
});
});
test("calculateAccelerationAt with sparse array", () => {
const history: HistoryPoint<Vector3>[] = [];
history.length = 3;
history[2] = { value: { x: 1, y: 0, z: 0 }, timestamp: 1000 };
expect(calculateAccelerationAt(history, 2)).toEqual({
x: 0,
y: 0,
z: 0,
});
});
test("calculateJerk with < 4 points", () => {
expect(calculateJerk([], calculateAccelerationAt)).toEqual({
x: 0,
y: 0,
z: 0,
});
});
test("calculateJerk normal", () => {
const history = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 1, y: 0, z: 0 }, timestamp: 1000 },
{ value: { x: 4, y: 0, z: 0 }, timestamp: 2000 },
{ value: { x: 10, y: 0, z: 0 }, timestamp: 3000 },
];
const result = calculateJerk(history, calculateAccelerationAt);
expect(typeof result.x).toBe("number");
});
test("calculateJerk with dt <= 0", () => {
const history = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 1, y: 0, z: 0 }, timestamp: 1000 },
{ value: { x: 4, y: 0, z: 0 }, timestamp: 2000 },
{ value: { x: 10, y: 0, z: 0 }, timestamp: 2000 },
];
expect(calculateJerk(history, calculateAccelerationAt)).toEqual({
x: 0,
y: 0,
z: 0,
});
});
test("calculateJerk with sparse array", () => {
const history: HistoryPoint<Vector3>[] = [];
history.length = 4;
history[3] = { value: { x: 1, y: 0, z: 0 }, timestamp: 1000 };
expect(calculateJerk(history, calculateAccelerationAt)).toEqual({
x: 0,
y: 0,
z: 0,
});
});
});
describe("prediction-algorithms", () => {
const history: HistoryPoint<Vector3>[] = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 10, y: 0, z: 0 }, timestamp: 1000 },
];
const velocity = { x: 10, y: 0, z: 0 };
const acceleration = { x: 0, y: 0, z: 0 };
const jerk = { x: 0, y: 0, z: 0 };
test("linearPredict normal", () => {
const result = linearPredict(history, velocity, 0.1);
expect(result.x).toBe(11);
});
test("linearPredict throws on sparse array", () => {
const sparse: HistoryPoint<Vector3>[] = [];
expect(() => linearPredict(sparse, velocity, 0.1)).toThrow(
"History item required for prediction"
);
});
test("quadraticPredict normal", () => {
const result = quadraticPredict(history, velocity, acceleration, 0.1);
expect(result.x).toBe(11);
});
test("quadraticPredict throws on sparse array", () => {
const sparse: HistoryPoint<Vector3>[] = [];
expect(() =>
quadraticPredict(sparse, velocity, acceleration, 0.1)
).toThrow("History item required for prediction");
});
test("cubicPredict normal", () => {
const result = cubicPredict(history, velocity, acceleration, jerk, 0.1);
expect(result.x).toBe(11);
});
test("cubicPredict throws on sparse array", () => {
const sparse: HistoryPoint<Vector3>[] = [];
expect(() =>
cubicPredict(sparse, velocity, acceleration, jerk, 0.1)
).toThrow("History item required for prediction");
});
test("hermitePredict with < 3 points falls back to linear", () => {
const result = hermitePredict(history, velocity, 0.1);
expect(result.x).toBe(11);
});
test("hermitePredict with >= 3 points", () => {
const h3 = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 5, y: 0, z: 0 }, timestamp: 500 },
{ value: { x: 10, y: 0, z: 0 }, timestamp: 1000 },
];
const result = hermitePredict(h3, velocity, 0.1);
expect(typeof result.x).toBe("number");
});
test("hermitePredict throws on sparse array", () => {
const sparse: HistoryPoint<Vector3>[] = [];
sparse.length = 3;
sparse[2] = { value: { x: 10, y: 0, z: 0 }, timestamp: 1000 };
expect(() => hermitePredict(sparse, velocity, 0.1)).toThrow(
"History items required for Hermite prediction"
);
});
test("catmullRomPredict with < 4 points falls back to linear", () => {
const result = catmullRomPredict(history, 0.1);
expect(typeof result.x).toBe("number");
});
test("catmullRomPredict with < 4 points and timeDt <= 0", () => {
const hSame = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 1000 },
{ value: { x: 10, y: 0, z: 0 }, timestamp: 1000 },
];
const result = catmullRomPredict(hSame, 0.1);
// Falls back to linearPredict with zero velocity -> returns last position
expect(result).toEqual({ x: 10, y: 0, z: 0 });
});
test("catmullRomPredict with < 4 points and missing prev", () => {
const sparse: HistoryPoint<Vector3>[] = [];
sparse.length = 2;
sparse[1] = { value: { x: 10, y: 0, z: 0 }, timestamp: 1000 };
const result = catmullRomPredict(sparse, 0.1);
expect(result).toEqual({ x: 0, y: 0, z: 0 });
});
test("catmullRomPredict with >= 4 points", () => {
const h4 = [
{ value: { x: 0, y: 0, z: 0 }, timestamp: 0 },
{ value: { x: 3, y: 0, z: 0 }, timestamp: 300 },
{ value: { x: 7, y: 0, z: 0 }, timestamp: 700 },
{ value: { x: 10, y: 0, z: 0 }, timestamp: 1000 },
];
const result = catmullRomPredict(h4, 0.1);
expect(typeof result.x).toBe("number");
});
test("catmullRomPredict throws on sparse array", () => {
const sparse: HistoryPoint<Vector3>[] = [];
sparse.length = 4;
sparse[3] = { value: { x: 10, y: 0, z: 0 }, timestamp: 1000 };
expect(() => catmullRomPredict(sparse, 0.1)).toThrow(
"History items required for Catmull-Rom prediction"
);
});
test("verletPredict normal", () => {
const result = verletPredict(history, velocity, acceleration, 0.1, {
gravityFactor: 9.8,
velocityDamping: 0.98,
});
expect(typeof result.x).toBe("number");
expect(typeof result.z).toBe("number");
});
test("verletPredict with default options", () => {
const result = verletPredict(history, velocity, acceleration, 0.1, {});
expect(typeof result.x).toBe("number");
expect(typeof result.z).toBe("number");
});
test("verletPredict throws on sparse array", () => {
const sparse: HistoryPoint<Vector3>[] = [];
expect(() =>
verletPredict(sparse, velocity, acceleration, 0.1, {})
).toThrow("History item required for prediction");
});
test("verletPredict throws when lastItem is undefined", () => {
const sparse: HistoryPoint<Vector3>[] = [];
sparse.length = 1;
expect(() =>
verletPredict(sparse, velocity, acceleration, 0.1, {})
).toThrow("History item required for prediction");
});
});
describe("PositionPredictor", () => {
test("constructor with KALMAN algorithm", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.KALMAN,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 1, y: 0, z: 0 }, 1000);
const result = predictor.predictPosition(100);
expect(result).not.toBeNull();
});
test("constructor with undefined options uses defaults", () => {
const predictor = new PositionPredictor({
algorithm: undefined as unknown as PredictionAlgorithm,
historySize: undefined as unknown as number,
maxPredictionTime: undefined as unknown as number,
adaptiveThreshold: undefined as unknown as number,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 1, y: 0, z: 0 }, 1000);
const result = predictor.predictPosition(100);
expect(result).not.toBeNull();
});
test("adaptAlgorithm with undefined adaptiveThreshold", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.ADAPTIVE,
adaptiveThreshold: undefined as unknown as number,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 1, y: 0, z: 0 }, 1000);
predictor.predictPosition(100);
predictor.addPosition({ x: 100, y: 0, z: 0 }, 2000);
expect(predictor.getPredictionError()).toBeGreaterThan(0);
});
test("constructor with ADAPTIVE algorithm", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.ADAPTIVE,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 1, y: 0, z: 0 }, 1000);
const result = predictor.predictPosition(100);
expect(result).not.toBeNull();
});
test("predictPosition with < 2 points returns null", () => {
const predictor = new PositionPredictor();
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
expect(predictor.predictPosition(100)).toBeNull();
});
test("predictPosition LINEAR", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.LINEAR,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
const result = predictor.predictPosition(100);
expect(result!.x).toBeGreaterThan(10);
});
test("addPosition with lastPrediction and non-adaptive algorithm", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.LINEAR,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
predictor.predictPosition(100);
predictor.addPosition({ x: 15, y: 0, z: 0 }, 2000);
expect(predictor.getPredictionError()).toBeGreaterThan(0);
});
test("predictPosition QUADRATIC", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.QUADRATIC,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
const result = predictor.predictPosition(100);
expect(result).not.toBeNull();
});
test("predictPosition CUBIC", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.CUBIC,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
const result = predictor.predictPosition(100);
expect(result).not.toBeNull();
});
test("predictPosition HERMITE", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.HERMITE,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 5, y: 0, z: 0 }, 500);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
const result = predictor.predictPosition(100);
expect(result).not.toBeNull();
});
test("predictPosition CATMULL_ROM", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.CATMULL_ROM,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 3, y: 0, z: 0 }, 300);
predictor.addPosition({ x: 7, y: 0, z: 0 }, 700);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
const result = predictor.predictPosition(100);
expect(result).not.toBeNull();
});
test("predictPosition VELOCITY_VERLET", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.VELOCITY_VERLET,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
const result = predictor.predictPosition(100);
expect(result).not.toBeNull();
});
test("predictPosition KALMAN without initial kalmanState", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.KALMAN,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
const result = predictor.predictPosition(100);
expect(result).not.toBeNull();
});
test("predictPosition KALMAN with null kalmanState falls back and initializes", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.KALMAN,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
// Force kalmanState to null via internal access
(predictor as unknown as { kalmanState: null }).kalmanState = null;
const result = predictor.predictPosition(100);
expect(result).not.toBeNull();
});
test("predictPosition default fallback", () => {
const predictor = new PositionPredictor({
algorithm: "unknown" as PredictionAlgorithm,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
const result = predictor.predictPosition(100);
expect(result).not.toBeNull();
});
test("predictPosition limits deltaTime to maxPredictionTime", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.LINEAR,
maxPredictionTime: 50,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
const result = predictor.predictPosition(1000);
expect(result).not.toBeNull();
});
test("adaptAlgorithm switches to KALMAN on large error", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.ADAPTIVE,
adaptiveThreshold: 0.1,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 1, y: 0, z: 0 }, 1000);
// Trigger a prediction to set lastPrediction
predictor.predictPosition(100);
// Add a position far from prediction to cause large error
predictor.addPosition({ x: 100, y: 0, z: 0 }, 2000);
expect(predictor.getPredictionError()).toBeGreaterThan(0.2);
});
test("adaptAlgorithm KALMAN branch with existing kalmanState", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.ADAPTIVE,
adaptiveThreshold: 0.1,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 1, y: 0, z: 0 }, 1000);
predictor.predictPosition(100);
// First large error initializes kalmanState
predictor.addPosition({ x: 100, y: 0, z: 0 }, 2000);
// Small error to switch away from KALMAN
predictor.predictPosition(100);
predictor.addPosition({ x: 100.1, y: 0, z: 0 }, 3000);
// Another large error - kalmanState already exists
predictor.predictPosition(100);
predictor.addPosition({ x: 200, y: 0, z: 0 }, 4000);
expect(predictor.getPredictionError()).toBeGreaterThan(0);
});
test("adaptAlgorithm switches to VERLET on medium error", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.ADAPTIVE,
adaptiveThreshold: 0.1,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 10, y: 0, z: 0 }, 1000);
predictor.predictPosition(100);
// Predicted ~11, actual 11.15 -> error 0.15, which is > 0.1 and <= 0.2
predictor.addPosition({ x: 11.15, y: 0, z: 0 }, 2000);
expect(predictor.getPredictionError()).toBeGreaterThan(0.1);
expect(predictor.getPredictionError()).toBeLessThanOrEqual(0.2);
});
test("adaptAlgorithm stays CUBIC on small error", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.ADAPTIVE,
adaptiveThreshold: 0.1,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 1, y: 0, z: 0 }, 1000);
predictor.predictPosition(100);
predictor.addPosition({ x: 1.1, y: 0, z: 0 }, 2000);
expect(predictor.getPredictionError()).toBeGreaterThanOrEqual(0);
});
test("addPosition trims history", () => {
const predictor = new PositionPredictor({ historySize: 2 });
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 1, y: 0, z: 0 }, 1000);
predictor.addPosition({ x: 2, y: 0, z: 0 }, 2000);
// Should still work with trimmed history
const result = predictor.predictPosition(100);
expect(result).not.toBeNull();
});
test("addPosition without prior prediction does not calculate error", () => {
const predictor = new PositionPredictor();
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 1, y: 0, z: 0 }, 1000);
predictor.addPosition({ x: 2, y: 0, z: 0 }, 2000);
expect(predictor.getPredictionError()).toBe(0);
});
test("clearHistory resets predictor", () => {
const predictor = new PositionPredictor();
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 1, y: 0, z: 0 }, 1000);
predictor.clearHistory();
expect(predictor.predictPosition(100)).toBeNull();
expect(predictor.getPredictionError()).toBe(0);
});
test("clearHistory with KALMAN reinitializes state", () => {
const predictor = new PositionPredictor({
algorithm: PredictionAlgorithm.KALMAN,
});
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 1, y: 0, z: 0 }, 1000);
predictor.clearHistory();
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 1, y: 0, z: 0 }, 1000);
expect(predictor.predictPosition(100)).not.toBeNull();
});
test("setOptions updates options and initializes KALMAN state", () => {
const predictor = new PositionPredictor();
predictor.setOptions({ algorithm: PredictionAlgorithm.KALMAN });
predictor.addPosition({ x: 0, y: 0, z: 0 }, 0);
predictor.addPosition({ x: 1, y: 0, z: 0 }, 1000);
expect(predictor.predictPosition(100)).not.toBeNull();
});
test("setOptions without KALMAN does not initialize state", () => {
const predictor = new PositionPredictor();
predictor.setOptions({ historySize: 3 });
expect(predictor.getPredictionError()).toBe(0);
});
});
describe("RotationPredictor", () => {
test("constructor with options", () => {
const predictor = new RotationPredictor({ historySize: 3 });
expect(predictor.getPredictionError()).toBe(0);
});
test("constructor with falsy options uses defaults", () => {
const predictor = new RotationPredictor({
historySize: 0,
maxPredictionTime: 0,
adaptiveThreshold: 0,
});
predictor.addRotation({ yaw: 0, pitch: 0 }, 0);
predictor.addRotation({ yaw: 10, pitch: 0 }, 1000);
const result = predictor.predictRotation(100);
expect(result).not.toBeNull();
});
test("predictRotation with < 2 points returns null", () => {
const predictor = new RotationPredictor();
predictor.addRotation({ yaw: 0, pitch: 0 }, 0);
expect(predictor.predictRotation(100)).toBeNull();
});
test("predictRotation normal", () => {
const predictor = new RotationPredictor();
predictor.addRotation({ yaw: 0, pitch: 0 }, 0);
predictor.addRotation({ yaw: 10, pitch: 5 }, 1000);
const result = predictor.predictRotation(100);
expect(result).not.toBeNull();
expect(result!.yaw).toBeGreaterThan(0);
});
test("predictRotation handles sparse history", () => {
const predictor = new RotationPredictor();
// Make history sparse: length 2 but only index 1 defined
const sparseHistory = [] as {
value: { yaw: number; pitch: number };
timestamp: number;
}[];
sparseHistory.length = 2;
sparseHistory[1] = { value: { yaw: 10, pitch: 5 }, timestamp: 1000 };
(predictor as unknown as { history: typeof sparseHistory }).history =
sparseHistory;
const result = predictor.predictRotation(100);
expect(result).not.toBeNull();
});
test("addRotation trims history", () => {
const predictor = new RotationPredictor({ historySize: 2 });
predictor.addRotation({ yaw: 0, pitch: 0 }, 0);
predictor.addRotation({ yaw: 10, pitch: 0 }, 1000);
predictor.addRotation({ yaw: 20, pitch: 0 }, 2000);
const result = predictor.predictRotation(100);
expect(result).not.toBeNull();
});
test("addRotation calculates prediction error", () => {
const predictor = new RotationPredictor();
predictor.addRotation({ yaw: 0, pitch: 0 }, 0);
predictor.addRotation({ yaw: 10, pitch: 0 }, 1000);
predictor.predictRotation(100);
predictor.addRotation({ yaw: 25, pitch: 0 }, 2000);
expect(predictor.getPredictionError()).toBeGreaterThan(0);
});
test("clearHistory resets predictor", () => {
const predictor = new RotationPredictor();
predictor.addRotation({ yaw: 0, pitch: 0 }, 0);
predictor.addRotation({ yaw: 10, pitch: 0 }, 1000);
predictor.clearHistory();
expect(predictor.predictRotation(100)).toBeNull();
expect(predictor.getPredictionError()).toBe(0);
});
test("setOptions updates options", () => {
const predictor = new RotationPredictor();
predictor.setOptions({ historySize: 5 });
expect(predictor.getPredictionError()).toBe(0);
});
test("angleDifference handles wrap-around", () => {
const predictor = new RotationPredictor();
predictor.addRotation({ yaw: 170, pitch: 0 }, 0);
predictor.addRotation({ yaw: -170, pitch: 0 }, 1000);
predictor.predictRotation(100);
predictor.addRotation({ yaw: 170, pitch: 0 }, 2000);
// Error should be computed correctly across wrap-around
expect(predictor.getPredictionError()).toBeGreaterThanOrEqual(0);
});
test("calculateAngularVelocity with dt <= 0", () => {
const predictor = new RotationPredictor();
predictor.addRotation({ yaw: 0, pitch: 0 }, 0);
predictor.addRotation({ yaw: 10, pitch: 0 }, 0);
const result = predictor.predictRotation(100);
expect(result).not.toBeNull();
});
test("calculateAngularVelocity with history length < 2", () => {
const predictor = new RotationPredictor();
const result = (
predictor as unknown as {
calculateAngularVelocity: () => { yaw: number; pitch: number };
}
).calculateAngularVelocity();
expect(result).toEqual({ yaw: 0, pitch: 0 });
});
test("calculateAngularVelocity with undefined prev", () => {
const predictor = new RotationPredictor();
predictor.addRotation({ yaw: 0, pitch: 0 }, 0);
predictor.addRotation({ yaw: 10, pitch: 0 }, 1000);
predictor.addRotation({ yaw: 20, pitch: 0 }, 2000);
// Sparse history where prev (index 1) is undefined
const sparseHistory = [
{ value: { yaw: 0, pitch: 0 }, timestamp: 0 },
undefined as unknown as {
value: { yaw: number; pitch: number };
timestamp: number;
},
{ value: { yaw: 20, pitch: 0 }, timestamp: 2000 },
];
(predictor as unknown as { history: typeof sparseHistory }).history =
sparseHistory;
const result = predictor.predictRotation(100);
expect(result).not.toBeNull();
});
test("linearPredictRotation throws when lastItem is undefined", () => {
const predictor = new RotationPredictor();
predictor.addRotation({ yaw: 0, pitch: 0 }, 0);
predictor.addRotation({ yaw: 10, pitch: 0 }, 1000);
// Create a sparse history to trigger the throw
const sparseHistory = [{ value: { yaw: 0, pitch: 0 }, timestamp: 0 }];
sparseHistory.length = 2;
(predictor as unknown as { history: typeof sparseHistory }).history =
sparseHistory;
expect(() => predictor.predictRotation(100)).toThrow(
"History item required for rotation prediction"
);
});
});

View File

@@ -0,0 +1,538 @@
import { describe, expect, test } from "@suaveplan/testing/runner";
import { PredictionAlgorithm, StateReconciliation } from "../index.js";
import type { PlayerInput, PlayerState } from "../types.js";
import { InterpolationMethodEnum } from "../types.js";
const defaultInput = (): PlayerInput => ({
moveX: 1,
moveY: 0,
rotateYaw: 0,
rotatePitch: 0,
actions: [],
});
const defaultState = (): PlayerState => ({
position: { x: 0, y: 0, z: 0 },
rotation: { yaw: 0, pitch: 0 },
stats: {
health: 100,
shield: 50,
speed: 5,
isMoving: false,
isSprinting: false,
},
});
const applyPhysics = (
state: PlayerState,
input: PlayerInput,
deltaTime: number
): PlayerState => ({
...state,
position: {
x: state.position.x + input.moveX * deltaTime * 10,
y: state.position.y + input.moveY * deltaTime * 10,
z: state.position.z,
},
});
describe("StateReconciliation", () => {
test("constructor with options", () => {
const sr = new StateReconciliation({}, { inputBufferSize: 10 });
expect(sr.getOptions().inputBufferSize).toBe(10);
});
test("constructor with undefined nested options uses defaults", () => {
const sr = new StateReconciliation(
{},
{
predictionOptions: undefined,
interpolationOptions: undefined,
}
);
// Constructor should handle undefined nested options via || defaults
expect(sr.getOptions().inputBufferSize).toBe(60);
});
test("undefined top-level options use defaults", () => {
const sr = new StateReconciliation(
{},
{
inputBufferSize: undefined,
stateBufferSize: undefined,
jitterBufferSize: undefined,
reconciliationThreshold: undefined,
smoothingFactor: undefined,
serverDelay: undefined,
rollingAverageWindow: undefined,
maxExtrapolationTime: undefined,
enableRollingAverage: true,
enableExtrapolation: true,
enableJitterBuffer: true,
}
);
// Exercise all ?? branches with undefined left operands
sr.processInput(defaultInput(), 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
sr.processServerUpdate(defaultState(), 0, Date.now());
sr.reconcileState(defaultState(), applyPhysics);
sr.sendPing();
sr.receivePong();
sr.extrapolateState(defaultState(), 1000);
// Verify it works without throwing
expect(sr.getClientSequence()).toBe(0);
});
test("setBitPacker", () => {
const sr = new StateReconciliation({});
const mockPacker = {
update: () => {},
packState: () => new Uint8Array(),
packDelta: () => new Uint8Array(),
applyUpdate: () => true,
getState: () => null,
reset: () => {},
isInitialized: () => true,
getSequence: () => 0,
getLastReceivedSequence: () => 0,
processInput: () => 0,
predictClientState: () => null,
processServerUpdate: () => false,
reconcileState: () => null,
};
sr.setBitPacker(
mockPacker as unknown as Parameters<typeof sr.setBitPacker>[0]
);
expect(sr.getOptions().inputBufferSize).toBe(60);
});
test("processInput assigns sequence numbers", () => {
const sr = new StateReconciliation({});
const seq1 = sr.processInput(defaultInput(), 0);
const seq2 = sr.processInput(defaultInput(), 16);
expect(seq1).toBe(0);
expect(seq2).toBe(1);
expect(sr.getClientSequence()).toBe(1);
});
test("processInput trims buffer", () => {
const sr = new StateReconciliation({}, { inputBufferSize: 2 });
sr.processInput(defaultInput(), 0);
sr.processInput(defaultInput(), 16);
sr.processInput(defaultInput(), 32);
expect(sr.getClientSequence()).toBe(2);
});
test("predictClientState with no unprocessed inputs", () => {
const sr = new StateReconciliation({});
const state = defaultState();
const result = sr.predictClientState(state, applyPhysics, 0.016);
expect(result).toEqual(state);
});
test("predictClientState with unprocessed inputs", () => {
const sr = new StateReconciliation({});
sr.processInput(defaultInput(), 0);
const state = defaultState();
const result = sr.predictClientState(state, applyPhysics, 0.016);
expect(result.position.x).toBeGreaterThan(0);
});
test("predictClientState trims state buffer", () => {
const sr = new StateReconciliation({}, { stateBufferSize: 2 });
sr.processInput(defaultInput(), 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
sr.processInput(defaultInput(), 16);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
sr.processInput(defaultInput(), 32);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
// Should not throw
expect(sr.getClientSequence()).toBe(2);
});
test("processServerUpdate without jitter buffer", () => {
const sr = new StateReconciliation({}, { enableJitterBuffer: false });
const state = defaultState();
const result = sr.processServerUpdate(state, 0, Date.now());
expect(typeof result).toBe("boolean");
});
test("processServerUpdate with jitter buffer", () => {
const sr = new StateReconciliation(
{},
{ enableJitterBuffer: true, jitterBufferSize: 2 }
);
const state = defaultState();
sr.processServerUpdate(state, 0, Date.now());
const result = sr.processServerUpdate(state, 1, Date.now());
// First item should be processed when buffer exceeds size
expect(typeof result).toBe("boolean");
});
test("processServerUpdate with out-of-order sequence", () => {
const sr = new StateReconciliation({}, { enableJitterBuffer: false });
const state = defaultState();
sr.processServerUpdate(state, 5, Date.now());
const result = sr.processServerUpdate(state, 3, Date.now());
expect(result).toBe(false);
});
test("processServerUpdate triggers reconciliation on large error", () => {
const sr = new StateReconciliation(
{},
{ enableJitterBuffer: false, reconciliationThreshold: 0.1 }
);
const input = defaultInput();
sr.processInput(input, 0);
const _predicted = sr.predictClientState(
defaultState(),
applyPhysics,
0.016
);
// Server state with large position difference
const serverState = {
...defaultState(),
position: { x: 100, y: 100, z: 100 },
};
const result = sr.processServerUpdate(serverState, 0, Date.now());
expect(result).toBe(true);
expect(sr.getReconciliationError()).toBeGreaterThan(0.1);
expect(sr.getReconciliationCount()).toBe(1);
});
test("processServerUpdate does not reconcile when error below threshold", () => {
const sr = new StateReconciliation(
{},
{ enableJitterBuffer: false, reconciliationThreshold: 10 }
);
const input = defaultInput();
sr.processInput(input, 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
const serverState = defaultState();
const result = sr.processServerUpdate(serverState, 0, Date.now());
expect(result).toBe(false);
});
test("processServerUpdate with predicted input but no matching state buffer", () => {
const sr = new StateReconciliation(
{},
{ enableJitterBuffer: false, stateBufferSize: 1 }
);
const input = defaultInput();
sr.processInput(input, 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
// Overflow state buffer to evict the predicted state
sr.processInput(input, 16);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
const serverState = {
...defaultState(),
position: { x: 100, y: 0, z: 0 },
};
// Server sequence 0 was predicted but evicted from state buffer
const result = sr.processServerUpdate(serverState, 0, Date.now());
expect(result).toBe(false);
});
test("reconcileState with no server state returns current state", () => {
const sr = new StateReconciliation({});
const state = defaultState();
const result = sr.reconcileState(state, applyPhysics);
expect(result).toEqual(state);
});
test("reconcileState with server state but no later inputs", () => {
const sr = new StateReconciliation({}, { enableJitterBuffer: false });
sr.processInput(defaultInput(), 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
sr.processServerUpdate(defaultState(), 0, Date.now());
const result = sr.reconcileState(defaultState(), applyPhysics);
expect(result).toEqual(defaultState());
});
test("reconcileState with later inputs", () => {
const sr = new StateReconciliation({}, { enableJitterBuffer: false });
sr.processInput({ ...defaultInput(), moveX: 1 }, 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
sr.processInput({ ...defaultInput(), moveX: 1 }, 16);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
const serverState = defaultState();
sr.processServerUpdate(serverState, 0, Date.now());
const currentState = defaultState();
const result = sr.reconcileState(currentState, applyPhysics);
expect(result).not.toEqual(currentState);
});
test("reconcileState with prevInput lookup", () => {
const sr = new StateReconciliation({}, { enableJitterBuffer: false });
sr.processInput({ ...defaultInput(), moveX: 1 }, 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
sr.processInput({ ...defaultInput(), moveX: 1 }, 16);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
sr.processInput({ ...defaultInput(), moveX: 1 }, 32);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
const serverState = defaultState();
sr.processServerUpdate(serverState, 1, Date.now());
const currentState = defaultState();
const result = sr.reconcileState(currentState, applyPhysics);
expect(result).not.toEqual(currentState);
});
test("reconcileState with missing prevInput", () => {
const sr = new StateReconciliation(
{},
{ enableJitterBuffer: false, inputBufferSize: 1 }
);
sr.processInput({ ...defaultInput(), moveX: 1 }, 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
sr.processInput({ ...defaultInput(), moveX: 1 }, 16);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
const serverState = defaultState();
// Server sequence 0, later input is seq 1
// But input buffer only has seq 1 (seq 0 was evicted)
sr.processServerUpdate(serverState, 0, Date.now());
const currentState = defaultState();
const result = sr.reconcileState(currentState, applyPhysics);
expect(result).not.toEqual(currentState);
});
test("updatePing and getAveragePing", () => {
const sr = new StateReconciliation({});
sr.updatePing(50);
sr.updatePing(100);
expect(sr.getAveragePing()).toBe(75);
});
test("updatePing trims history", () => {
const sr = new StateReconciliation({}, { rollingAverageWindow: 2 });
sr.updatePing(50);
sr.updatePing(100);
sr.updatePing(150);
expect(sr.getAveragePing()).toBe(125);
});
test("sendPing and receivePong", () => {
const sr = new StateReconciliation({});
sr.sendPing();
sr.receivePong();
expect(sr.getAveragePing()).toBeGreaterThanOrEqual(0);
});
test("extrapolateState disabled", () => {
const sr = new StateReconciliation({}, { enableExtrapolation: false });
const state = defaultState();
const result = sr.extrapolateState(state, 0.1);
expect(result).toEqual(state);
});
test("extrapolateState enabled", () => {
const sr = new StateReconciliation({}, { enableExtrapolation: true });
sr.processInput(defaultInput(), 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
const state = defaultState();
const result = sr.extrapolateState(state, 0.1);
expect(result).not.toBeNull();
});
test("extrapolateState with null predictedPosition returns current state", () => {
const sr = new StateReconciliation({}, { enableExtrapolation: true });
// Never call predictClientState, so positionPredictor has empty history
const state = defaultState();
const result = sr.extrapolateState(state, 0.1);
expect(result).toEqual(state);
});
test("extrapolateState with predictedPosition returns updated state", () => {
const sr = new StateReconciliation({}, { enableExtrapolation: true });
sr.processInput(defaultInput(), 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
sr.processInput(defaultInput(), 16);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
const state = defaultState();
const result = sr.extrapolateState(state, 0.1);
expect(result.position).not.toEqual(state.position);
});
test("extrapolateState with maxExtrapolationTime", () => {
const sr = new StateReconciliation(
{},
{ enableExtrapolation: true, maxExtrapolationTime: 50 }
);
sr.processInput(defaultInput(), 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
const state = defaultState();
const result = sr.extrapolateState(state, 1000);
expect(result).not.toBeNull();
});
test("getServerSequence", () => {
const sr = new StateReconciliation({}, { enableJitterBuffer: false });
expect(sr.getServerSequence()).toBe(-1);
sr.processServerUpdate(defaultState(), 5, Date.now());
expect(sr.getServerSequence()).toBe(5);
});
test("getServerTimeOffset with rolling average", () => {
const sr = new StateReconciliation({}, { enableRollingAverage: true });
sr.processServerUpdate(defaultState(), 0, Date.now() - 100);
expect(sr.getServerTimeOffset()).not.toBe(0);
});
test("getServerTimeOffset without rolling average", () => {
const sr = new StateReconciliation({}, { enableRollingAverage: false });
sr.processServerUpdate(defaultState(), 0, Date.now() - 100);
expect(sr.getServerTimeOffset()).not.toBe(0);
});
test("getServerTimeOffset first rolling average sets directly", () => {
const sr = new StateReconciliation({}, { enableRollingAverage: true });
sr.processServerUpdate(defaultState(), 0, Date.now());
// First call sets offset directly since it was 0
expect(sr.getServerTimeOffset()).not.toBe(0);
});
test("getEstimatedServerDelay with empty ping history", () => {
const sr = new StateReconciliation({}, { serverDelay: 200 });
sr.processServerUpdate(defaultState(), 0, Date.now());
// processServerUpdate calls updateServerTimeOffset which calls getEstimatedServerDelay
expect(sr.getServerTimeOffset()).not.toBe(0);
});
test("getEstimatedServerDelay with undefined serverDelay", () => {
const sr = new StateReconciliation({}, { serverDelay: undefined });
sr.processServerUpdate(defaultState(), 0, Date.now());
expect(sr.getServerTimeOffset()).not.toBe(0);
});
test("getEstimatedServerDelay with ping history", () => {
const sr = new StateReconciliation({});
sr.updatePing(100);
sr.processServerUpdate(defaultState(), 0, Date.now());
expect(sr.getAveragePing()).toBe(100);
});
test("reset clears all state", () => {
const sr = new StateReconciliation({});
sr.processInput(defaultInput(), 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
sr.processServerUpdate(defaultState(), 0, Date.now());
sr.updatePing(50);
sr.reset();
expect(sr.getClientSequence()).toBe(-1);
expect(sr.getServerSequence()).toBe(-1);
expect(sr.getReconciliationCount()).toBe(0);
expect(sr.getReconciliationError()).toBe(0);
expect(sr.getAveragePing()).toBe(0);
expect(sr.getServerTimeOffset()).toBe(0);
});
test("setOptions updates options", () => {
const sr = new StateReconciliation({});
sr.setOptions({ reconciliationThreshold: 1.0 });
expect(sr.getOptions().reconciliationThreshold).toBe(1.0);
});
test("setOptions with predictionOptions", () => {
const sr = new StateReconciliation({});
sr.setOptions({
predictionOptions: { algorithm: PredictionAlgorithm.CUBIC },
});
expect(sr.getOptions().predictionOptions?.algorithm).toBe(
PredictionAlgorithm.CUBIC
);
});
test("setOptions with interpolationOptions", () => {
const sr = new StateReconciliation({});
sr.setOptions({
interpolationOptions: { method: InterpolationMethodEnum.CUBIC },
});
expect(sr.getOptions().interpolationOptions?.method).toBe(
InterpolationMethodEnum.CUBIC
);
});
test("getOptions returns copy", () => {
const sr = new StateReconciliation({});
const opts = sr.getOptions();
opts.reconciliationThreshold = 999;
expect(sr.getOptions().reconciliationThreshold).not.toBe(999);
});
test("jitter buffer processes oldest state when full", () => {
const sr = new StateReconciliation(
{},
{ enableJitterBuffer: true, jitterBufferSize: 1 }
);
const state1 = { ...defaultState(), position: { x: 1, y: 0, z: 0 } };
const state2 = { ...defaultState(), position: { x: 2, y: 0, z: 0 } };
sr.processServerUpdate(state1, 0, Date.now());
const result = sr.processServerUpdate(state2, 1, Date.now());
// When buffer exceeds size, oldest (state1) is processed
expect(typeof result).toBe("boolean");
});
test("processServerUpdate with inputEntry not predicted", () => {
const sr = new StateReconciliation({}, { enableJitterBuffer: false });
sr.processInput(defaultInput(), 0);
// Do NOT predict client state, so input is not marked predicted
const serverState = defaultState();
const result = sr.processServerUpdate(serverState, 0, Date.now());
expect(result).toBe(false);
});
test("angleDifference handles diff < -180", () => {
const sr = new StateReconciliation(
{},
{ enableJitterBuffer: false, reconciliationThreshold: 0.001 }
);
const input = defaultInput();
sr.processInput(input, 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
const serverState = {
...defaultState(),
rotation: { yaw: -190, pitch: 0 },
};
const result = sr.processServerUpdate(serverState, 0, Date.now());
expect(typeof result).toBe("boolean");
});
test("reconcileState smoothing", () => {
const sr = new StateReconciliation(
{},
{ enableJitterBuffer: false, smoothingFactor: 0.5 }
);
sr.processInput(defaultInput(), 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
const serverState = defaultState();
sr.processServerUpdate(serverState, 0, Date.now());
const currentState = {
...defaultState(),
position: { x: 50, y: 0, z: 0 },
};
const result = sr.reconcileState(currentState, applyPhysics);
// Should be smoothed between current and reconciled
expect(result.position.x).not.toBe(50);
});
test("findLatestServerState sorts multiple server states", () => {
const sr = new StateReconciliation({}, { enableJitterBuffer: false });
sr.processServerUpdate(defaultState(), 0, Date.now());
sr.processServerUpdate(
{ ...defaultState(), position: { x: 1, y: 0, z: 0 } },
1,
Date.now()
);
sr.processInput(defaultInput(), 0);
sr.predictClientState(defaultState(), applyPhysics, 0.016);
const result = sr.reconcileState(defaultState(), applyPhysics);
expect(result).not.toEqual(defaultState());
});
});

View File

@@ -0,0 +1,37 @@
## Game Types
**Path:** `src/types.ts`
**Export:** All game-related type definitions and enums
### Core Types
| Type | Description |
|------|-------------|
| `Vector3` | `{ x, y, z }` 3D position |
| `Rotation` | `{ yaw, pitch }` euler rotation |
| `PlayerStats` | `{ health, shield, speed, isMoving, isSprinting }` |
| `PlayerState` | `{ position, rotation, stats, optional? }` |
| `PlayerInput` | `{ moveX, moveY, rotateYaw, rotatePitch, actions }` |
| `HistoryPoint<T>` | `{ value: T, timestamp: number }` for prediction history |
| `KalmanState` | Kalman filter position/velocity/covariance state |
### Enums
| Enum | Values |
|------|--------|
| `PredictionAlgorithm` | LINEAR, QUADRATIC, CUBIC, HERMITE, CATMULL_ROM, VELOCITY_VERLET, KALMAN, ADAPTIVE |
| `InterpolationMethod` | LINEAR, CUBIC, HERMITE, CATMULL_ROM, BEZIER, SPRING, ELASTIC, BOUNCE, SMOOTHSTEP |
### Options Interfaces
| Interface | Key Fields |
|-----------|-----------|
| `PredictionOptions` | `algorithm`, `historySize`, `maxPredictionTime`, `adaptiveThreshold`, `gravityFactor`, `velocityDamping` |
| `InterpolationOptions` | `method`, `tension`, `bias`, `springConstant`, `damping` |
| `ReconciliationOptions` | `inputBufferSize`, `stateBufferSize`, `jitterBufferSize`, `reconciliationThreshold`, `smoothingFactor`, `enableExtrapolation` |
### Defaults
- `DEFAULT_PREDICTION_OPTIONS` — historySize: 5, maxPredictionTime: 200ms
- `DEFAULT_INTERPOLATION_OPTIONS` — method: LINEAR, tension: 0.5, bias: 0.5
- `DEFAULT_RECONCILIATION_OPTIONS` — inputBufferSize: 60, stateBufferSize: 120, jitterBufferSize: 3

View File

@@ -0,0 +1,8 @@
export {
gameBitPackerOptionsSchema,
gameStateSchemaSchema,
interpolationOptionsSchema,
predictionOptionsSchema,
quantizedVectorSchema,
reconciliationOptionsSchema,
} from "./types.schema.js";

View File

@@ -0,0 +1,109 @@
# Game Schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas for the public configuration types of `@suaveplan/bitpacker-game`: the three sub-options bundles (`PredictionOptions`, `InterpolationOptions`, `ReconciliationOptions`), the top-level `GameBitPackerOptions`, the simple `GameStateSchema` envelope, and the `QuantizedVector` shape used by `quantizeVector`/`dequantizeVector`. Each schema is the runtime gatekeeper at the corresponding constructor or function boundary.
The schemas are strict and reject unknown keys so a typo'd flag (`enablePredictoin` for `enablePrediction`) fails at parse time rather than producing a game that silently runs without prediction enabled. Numeric ranges encode genuine invariants — buffer sizes are non-negative integers, history sizes are positive integers (a zero-history predictor is degenerate), and timing values are non-negative finite numbers.
## Features
- `predictionOptionsSchema` — `algorithm` from `PredictionAlgorithm` enum, positive integer `historySize`, non-negative timing values
- `interpolationOptionsSchema` — `method` from the union of nine published method tokens
- `reconciliationOptionsSchema` — buffer sizes, jitter window, threshold, smoothing, plus nested `predictionOptions` and `interpolationOptions`
- `gameStateSchemaSchema` — versioned `{ version, entities }` envelope, `entities` is an open record (`Record<string, unknown>`)
- `quantizedVectorSchema` — `{ x, y, z? }` for 2-D and 3-D quantization
- `gameBitPackerOptionsSchema` — top-level GameBitPacker options, composing the three sub-bundles
- `as unknown as z.ZodType<T>` casts where Zod's optional `T | undefined` collides with `exactOptionalPropertyTypes`
## Usage: Basic Example
```ts
import {
GameBitPacker,
PredictionAlgorithm,
gameBitPackerOptionsSchema,
} from "@suaveplan/bitpacker-game";
const opts = gameBitPackerOptionsSchema.parse({
enablePrediction: true,
predictionOptions: {
algorithm: PredictionAlgorithm.KALMAN,
historySize: 10,
},
});
const packer = new GameBitPacker(opts);
```
## Usage: Advanced Example
```ts
import {
GameStateManager,
quantizeVector,
quantizedVectorSchema,
gameStateSchemaSchema,
reconciliationOptionsSchema,
PredictionAlgorithm,
} from "@suaveplan/bitpacker-game";
// Validate every external state push:
const manager = new GameStateManager();
function pushState(raw: unknown): void {
manager.pushState(gameStateSchemaSchema.parse(raw));
}
// Validate vectors at the quantization boundary:
function compressedVector(raw: unknown, bits: number) {
const v = quantizedVectorSchema.parse(raw);
return quantizeVector(v, bits);
}
// Compose nested options:
const reconciliation = reconciliationOptionsSchema.parse({
inputBufferSize: 60,
stateBufferSize: 120,
enableExtrapolation: true,
predictionOptions: {
algorithm: PredictionAlgorithm.HERMITE,
historySize: 8,
},
});
```
## API Reference
### `predictionOptionsSchema: z.ZodType<PredictionOptions>`
Strict schema. `algorithm` (if present) must be a `PredictionAlgorithm` enum value. `historySize` must be a positive integer; `maxPredictionTime`, `adaptiveThreshold` must be non-negative numbers. `gravityFactor` and `velocityDamping` are unconstrained finite numbers.
### `interpolationOptionsSchema: z.ZodType<InterpolationOptions>`
Strict schema. `method` is restricted to the nine published method tokens (`linear`, `cubic`, `elastic`, `spring`, `bounce`, `smoothstep`, `hermite`, `catmull-rom`, `bezier`). Other fields are unconstrained finite numbers.
### `reconciliationOptionsSchema: z.ZodType<ReconciliationOptions>`
Strict schema with nested `predictionOptions` and `interpolationOptions`. Buffer/window sizes are non-negative integers; thresholds and timings are non-negative numbers.
### `gameStateSchemaSchema: z.ZodType<GameStateSchema>`
Validates `{ version, entities }`. `version` is a non-negative integer; `entities` is an open record so per-game shapes can be embedded. Validating each entity is the consumer's responsibility.
### `quantizedVectorSchema: z.ZodType<QuantizedVector>`
Validates `{ x, y, z? }`. `z` is optional so the same schema accepts both 2-D and 3-D vectors.
### `gameBitPackerOptionsSchema: z.ZodType<GameBitPackerOptions>`
Strict top-level options schema, composing the three sub-bundles. `networkSimulationOptions` is left as an open record (`Record<string, unknown>`) because the canonical schema lives in `@suaveplan/bitpacker-network` and importing it here would tighten the coupling unnecessarily — consumers who want to validate it can run the network package's schema themselves.
## Implementation Notes
- **Open-record `entities` and `networkSimulationOptions`.** Both are intentionally `Record<string, unknown>` because per-entity / per-network-config shapes are out of this package's scope. Validating at finer granularity would require this package to know about every consumer's data model, which it does not.
- **`PredictionAlgorithm` is a string enum.** Zod's `z.nativeEnum` works on it directly. The enum's serialized values (`"linear"`, `"kalman"`, …) are the same as the runtime values, so the schema accepts both forms.
- **`as unknown as z.ZodType<T>` cast.** Same EOPT mismatch as elsewhere in this change. Runtime semantics are identical.
- **Strict mode everywhere.** A loose schema would silently drop typo'd flags, producing a game-bitpacker configured without the consumer's intended optimizations.

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PredictionAlgorithm } from "../types.js";
import {
gameBitPackerOptionsSchema,
gameStateSchemaSchema,
interpolationOptionsSchema,
predictionOptionsSchema,
quantizedVectorSchema,
reconciliationOptionsSchema,
} from "./types.schema.js";
describe("predictionOptionsSchema", () => {
it("accepts an empty bundle", () => {
expect(predictionOptionsSchema.parse({})).toEqual({});
});
it("accepts a populated bundle with a published algorithm", () => {
const opts = {
algorithm: PredictionAlgorithm.KALMAN,
historySize: 10,
maxPredictionTime: 250,
adaptiveThreshold: 0.2,
gravityFactor: 9.8,
velocityDamping: 0.97,
};
expect(predictionOptionsSchema.parse(opts)).toEqual(opts);
});
it("rejects non-positive historySize", () => {
expect(() =>
predictionOptionsSchema.parse({ historySize: 0 })
).toThrow();
});
it("rejects an unknown algorithm", () => {
expect(() =>
predictionOptionsSchema.parse({ algorithm: "moonshot" })
).toThrow();
});
});
describe("interpolationOptionsSchema", () => {
it("accepts a published method token", () => {
const opts = { method: "spring" as const, springConstant: 0.4 };
expect(interpolationOptionsSchema.parse(opts)).toEqual(opts);
});
it("rejects an unknown method", () => {
expect(() =>
interpolationOptionsSchema.parse({ method: "warp" })
).toThrow();
});
});
describe("reconciliationOptionsSchema", () => {
it("accepts an empty bundle", () => {
expect(reconciliationOptionsSchema.parse({})).toEqual({});
});
it("accepts nested predictionOptions", () => {
const opts = {
predictionOptions: { algorithm: PredictionAlgorithm.LINEAR },
stateBufferSize: 256,
};
expect(reconciliationOptionsSchema.parse(opts)).toEqual(opts);
});
});
describe("gameStateSchemaSchema", () => {
it("accepts a versioned envelope", () => {
const state = { version: 1, entities: { p1: { hp: 10 } } };
expect(gameStateSchemaSchema.parse(state)).toEqual(state);
});
it("rejects a negative version", () => {
expect(() =>
gameStateSchemaSchema.parse({ version: -1, entities: {} })
).toThrow();
});
});
describe("quantizedVectorSchema", () => {
it("accepts a 2-D vector", () => {
const v = { x: 0.5, y: -0.25 };
expect(quantizedVectorSchema.parse(v)).toEqual(v);
});
it("accepts a 3-D vector", () => {
const v = { x: 0.5, y: -0.25, z: 0.75 };
expect(quantizedVectorSchema.parse(v)).toEqual(v);
});
it("rejects unknown axes", () => {
expect(() =>
quantizedVectorSchema.parse({ x: 0, y: 0, w: 1 })
).toThrow();
});
});
describe("gameBitPackerOptionsSchema", () => {
it("accepts the documented enable flags", () => {
const opts = {
enablePrediction: true,
enableInterpolation: false,
enableReconciliation: true,
enableNetworkSimulation: false,
};
expect(gameBitPackerOptionsSchema.parse(opts)).toEqual(opts);
});
it("rejects unknown keys", () => {
expect(() =>
gameBitPackerOptionsSchema.parse({ enableTimeTravel: true })
).toThrow();
});
});

View File

@@ -0,0 +1,139 @@
import z from "zod";
import type {
GameBitPackerOptions,
InterpolationOptions,
PredictionOptions,
ReconciliationOptions,
} from "../types.js";
import { PredictionAlgorithm } from "../types.js";
const interpolationMethodTokens = z.union([
z.literal("linear"),
z.literal("cubic"),
z.literal("elastic"),
z.literal("spring"),
z.literal("bounce"),
z.literal("smoothstep"),
z.literal("hermite"),
z.literal("catmull-rom"),
z.literal("bezier"),
]);
/**
* Strict Zod schema for {@link PredictionOptions}.
*
* Validates the predictor's options bundle. Numeric fields are bounded to
* positive values (history size, prediction horizon, gravity, damping) and
* the algorithm token must be one of the published `PredictionAlgorithm`
* values.
*/
export const predictionOptionsSchema = z
.object({
algorithm: z.nativeEnum(PredictionAlgorithm).optional(),
historySize: z.number().int().positive().optional(),
maxPredictionTime: z.number().nonnegative().optional(),
adaptiveThreshold: z.number().nonnegative().optional(),
gravityFactor: z.number().optional(),
velocityDamping: z.number().optional(),
})
.strict() as unknown as z.ZodType<PredictionOptions>;
/**
* Strict Zod schema for {@link InterpolationOptions}.
*
* Validates the interpolator's options bundle. The method token accepts both
* the string-literal type (`"linear" | "cubic" | …`) and the
* `InterpolationMethodEnum` values, since the type union is `method?:
* InterpolationMethod | InterpolationMethodEnum`.
*/
export const interpolationOptionsSchema = z
.object({
method: interpolationMethodTokens.optional(),
tension: z.number().optional(),
bias: z.number().optional(),
springConstant: z.number().optional(),
damping: z.number().optional(),
gravity: z.number().optional(),
bounceRestitution: z.number().optional(),
})
.strict() as unknown as z.ZodType<InterpolationOptions>;
/**
* Strict Zod schema for {@link ReconciliationOptions}.
*
* Validates the reconciliation manager's options bundle. Buffer sizes and
* window counts are non-negative integers; thresholds, smoothing factors,
* and timing values are non-negative finite numbers.
*/
export const reconciliationOptionsSchema = z
.object({
inputBufferSize: z.number().int().nonnegative().optional(),
stateBufferSize: z.number().int().nonnegative().optional(),
jitterBufferSize: z.number().int().nonnegative().optional(),
enableJitterBuffer: z.boolean().optional(),
reconciliationThreshold: z.number().nonnegative().optional(),
smoothingFactor: z.number().nonnegative().optional(),
serverDelay: z.number().nonnegative().optional(),
enableRollingAverage: z.boolean().optional(),
rollingAverageWindow: z.number().int().nonnegative().optional(),
enableExtrapolation: z.boolean().optional(),
maxExtrapolationTime: z.number().nonnegative().optional(),
predictionOptions: predictionOptionsSchema.optional(),
interpolationOptions: interpolationOptionsSchema.optional(),
})
.strict() as unknown as z.ZodType<ReconciliationOptions>;
/**
* Strict Zod schema for the top-level `GameStateSchema` shape.
*
* Validates a simple `{ version: number; entities: Record<string, unknown> }`
* envelope. The `entities` map values are `unknown` because each game embeds
* its own per-entity shape — schema discrimination is the consumer's
* responsibility, not the bitpacker's.
*/
export const gameStateSchemaSchema = z
.object({
version: z.number().int().nonnegative(),
entities: z.record(z.string(), z.unknown()),
})
.strict();
/**
* Strict Zod schema for {@link QuantizedVector}.
*
* Validates the input to `quantizeVector` / `dequantizeVector`. The `z` axis
* is optional so callers can quantize 2-D and 3-D vectors with the same API.
*/
export const quantizedVectorSchema = z
.object({
x: z.number(),
y: z.number(),
z: z.number().optional(),
})
.strict();
/**
* Strict Zod schema for {@link GameBitPackerOptions}.
*
* Validates the constructor options bundle for `GameBitPacker`. Composes the
* three sub-options schemas (`prediction`, `interpolation`, `reconciliation`)
* and the network-simulation options shape from `@suaveplan/bitpacker-network`.
* The `networkSimulationOptions` field is left as `z.record` because the
* canonical schema lives in the network package — validating its shape here
* would require a runtime dependency on the network package's schema, which
* would couple the two packages tighter than necessary.
*/
export const gameBitPackerOptionsSchema = z
.object({
delta: z.boolean().optional(),
floatsAsStrings: z.boolean().optional(),
predictionOptions: predictionOptionsSchema.optional(),
interpolationOptions: interpolationOptionsSchema.optional(),
reconciliationOptions: reconciliationOptionsSchema.optional(),
networkSimulationOptions: z.record(z.string(), z.unknown()).optional(),
enablePrediction: z.boolean().optional(),
enableInterpolation: z.boolean().optional(),
enableReconciliation: z.boolean().optional(),
enableNetworkSimulation: z.boolean().optional(),
})
.strict() as unknown as z.ZodType<GameBitPackerOptions>;

View File

@@ -0,0 +1,435 @@
/**
* Core types for game state prediction, interpolation, and reconciliation.
*/
import type { NetworkSimulationOptions } from "@suaveplan/bitpacker-network";
// ==============================================
// Generic Game Types
// ==============================================
/**
* Generic game state type
*/
export interface GameState {
[key: string]: unknown;
}
/**
* Generic input data type
*/
export interface InputData {
[key: string]: unknown;
}
/**
* Physics application function type
*/
export type PhysicsFunction<S = GameState, I = InputData> = (
state: S,
input: I,
deltaTime: number
) => S;
/**
* Network statistics
*/
export interface NetworkStats {
packetsSent: number;
packetsReceived: number;
packetsLost: number;
packetsDelayed: number;
packetsReordered: number;
averageLatency: number;
averageJitter: number;
totalBytes: number;
}
/**
* State delta computation result
*/
export interface StateDelta<T> {
hasChanges: boolean;
delta: Partial<T>;
changedPaths: string[];
}
/**
* State validation result
*/
export interface ValidationResult {
isValid: boolean;
errors: string[];
}
/**
* Feature configuration for GameBitPacker
*/
export interface GameFeatures {
prediction?: boolean;
interpolation?: boolean;
reconciliation?: boolean;
networkSimulation?: boolean;
}
// ==============================================
// Vector / Rotation Types
// ==============================================
export interface Vector3 {
x: number;
y: number;
z: number;
}
export interface Vector2 {
x: number;
y: number;
}
export interface Rotation {
yaw: number;
pitch: number;
}
// ==============================================
// Player Types
// ==============================================
export interface PlayerStats {
health: number;
shield: number;
speed: number;
isMoving: boolean;
isSprinting: boolean;
}
export interface PlayerState {
position: Vector3;
rotation: Rotation;
stats: PlayerStats;
optional?:
| {
animation?: string | undefined;
effects?: string[] | undefined;
}
| undefined;
}
export interface PlayerInput {
moveX: number;
moveY: number;
rotateYaw: number;
rotatePitch: number;
actions: string[];
}
export type PlayerSchema = {
position: {
x: string;
y: string;
z: string;
};
rotation: {
yaw: string;
pitch: string;
};
stats: {
health: number;
shield: number;
speed: string;
isMoving: boolean;
isSprinting: boolean;
};
optional?: {
animation?: string;
effects?: number[];
};
};
// ==============================================
// History / Buffer Types
// ==============================================
export interface HistoryPoint<T> {
value: T;
timestamp: number;
}
export interface InputBufferEntry {
input: PlayerInput;
timestamp: number;
sequence: number;
processed: boolean;
predicted: boolean;
}
export interface StateBufferEntry {
state: PlayerState;
timestamp: number;
sequence: number;
isServerState: boolean;
}
// ==============================================
// Kalman Filter
// ==============================================
export interface KalmanState {
position: Vector3;
velocity: Vector3;
positionCovariance: number;
velocityCovariance: number;
processNoise: number;
measurementNoise: number;
}
// ==============================================
// Prediction
// ==============================================
export enum PredictionAlgorithm {
LINEAR = "linear",
QUADRATIC = "quadratic",
CUBIC = "cubic",
HERMITE = "hermite",
CATMULL_ROM = "catmull-rom",
VELOCITY_VERLET = "verlet",
KALMAN = "kalman",
ADAPTIVE = "adaptive",
}
export interface PredictionOptions {
algorithm?: PredictionAlgorithm;
historySize?: number;
maxPredictionTime?: number;
adaptiveThreshold?: number;
gravityFactor?: number;
velocityDamping?: number;
}
export const DEFAULT_PREDICTION_OPTIONS: Required<PredictionOptions> = {
algorithm: PredictionAlgorithm.LINEAR,
historySize: 5,
maxPredictionTime: 200,
adaptiveThreshold: 0.1,
gravityFactor: 9.8,
velocityDamping: 0.98,
};
// ==============================================
// Interpolation
// ==============================================
export type InterpolationMethod =
| "linear"
| "cubic"
| "elastic"
| "spring"
| "bounce"
| "smoothstep";
export enum InterpolationMethodEnum {
LINEAR = "linear",
CUBIC = "cubic",
HERMITE = "hermite",
CATMULL_ROM = "catmull-rom",
BEZIER = "bezier",
SPRING = "spring",
ELASTIC = "elastic",
BOUNCE = "bounce",
SMOOTHSTEP = "smoothstep",
}
export interface InterpolationOptions {
method?: InterpolationMethod | InterpolationMethodEnum;
tension?: number;
bias?: number;
springConstant?: number;
damping?: number;
gravity?: number;
bounceRestitution?: number;
}
export const DEFAULT_INTERPOLATION_OPTIONS: Required<InterpolationOptions> = {
method: InterpolationMethodEnum.LINEAR,
tension: 0.5,
bias: 0.5,
springConstant: 0.3,
damping: 0.7,
gravity: 9.8,
bounceRestitution: 0.3,
};
// ==============================================
// Reconciliation
// ==============================================
export interface ReconciliationOptions {
inputBufferSize?: number;
stateBufferSize?: number;
jitterBufferSize?: number;
enableJitterBuffer?: boolean;
reconciliationThreshold?: number;
smoothingFactor?: number;
serverDelay?: number;
enableRollingAverage?: boolean;
rollingAverageWindow?: number;
enableExtrapolation?: boolean;
maxExtrapolationTime?: number;
predictionOptions?: PredictionOptions;
interpolationOptions?: InterpolationOptions;
}
export const DEFAULT_RECONCILIATION_OPTIONS: Required<ReconciliationOptions> = {
inputBufferSize: 60,
stateBufferSize: 120,
jitterBufferSize: 3,
enableJitterBuffer: true,
reconciliationThreshold: 0.5,
smoothingFactor: 0.3,
serverDelay: 100,
enableRollingAverage: true,
rollingAverageWindow: 5,
enableExtrapolation: true,
maxExtrapolationTime: 150,
predictionOptions: DEFAULT_PREDICTION_OPTIONS,
interpolationOptions: DEFAULT_INTERPOLATION_OPTIONS,
};
// ==============================================
// Network Simulation (re-exported from @suaveplan/bitpacker-network)
// ==============================================
//
// 2026-05-02 audit: NetworkSimulator was previously implemented in
// `bitpacker-game/src/network/network-simulation.ts` (misplaced) with its
// type companions defined here. Both moved to `@suaveplan/bitpacker-network`.
// This file re-exports the types so existing bitpacker-game consumers don't
// break.
export {
DEFAULT_NETWORK_SIMULATION_OPTIONS,
NETWORK_CONDITIONS,
NetworkCondition,
type NetworkSimulationOptions,
type NetworkSimulationStats,
type PacketInfo,
PacketStatus,
} from "@suaveplan/bitpacker-network";
// ==============================================
// Utility Types
// ==============================================
export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[]
? RecursivePartial<U>[]
: T[P] extends object
? RecursivePartial<T[P]>
: T[P];
};
export interface ObjectWithKeys {
[key: string]: unknown;
}
// ==============================================
// Game BitPacker Types
// ==============================================
export interface GameBitPackerOptions {
delta?: boolean;
floatsAsStrings?: boolean;
predictionOptions?: PredictionOptions;
interpolationOptions?: InterpolationOptions;
reconciliationOptions?: ReconciliationOptions;
networkSimulationOptions?: NetworkSimulationOptions;
enablePrediction?: boolean;
enableInterpolation?: boolean;
enableReconciliation?: boolean;
enableNetworkSimulation?: boolean;
}
export const DEFAULT_GAME_BITPACKER_OPTIONS: GameBitPackerOptions = {
delta: true,
floatsAsStrings: false,
enablePrediction: false,
enableInterpolation: false,
enableReconciliation: false,
enableNetworkSimulation: false,
};
export interface IStateReconciliation<_T = unknown> {
setBitPacker(bitPacker: IGameBitPacker<_T>): void;
processInput(input: unknown, timestamp: number): number;
predictClientState(
currentState: unknown,
applyPhysics: (
state: unknown,
input: unknown,
deltaTime: number
) => unknown,
deltaTime: number
): unknown;
processServerUpdate(
serverState: unknown,
serverSequence: number,
serverTimestamp: number
): boolean;
reconcileState(
currentState: unknown,
applyPhysics: (
state: unknown,
input: unknown,
deltaTime: number
) => unknown
): unknown;
updatePing(pingTime: number): void;
sendPing(): void;
receivePong(): void;
extrapolateState(currentState: unknown, deltaTime: number): unknown;
getClientSequence(): number;
getServerSequence(): number;
getReconciliationError(): number;
getReconciliationCount(): number;
getServerTimeOffset(): number;
getAveragePing(): number;
reset(): void;
setOptions(options: ReconciliationOptions): void;
getOptions(): ReconciliationOptions;
}
export interface IGameBitPacker<_T = unknown> {
update(data: unknown, isInitialState?: boolean): void;
packState(): Uint8Array | undefined;
packDelta(): Uint8Array | undefined;
applyUpdate(data: Uint8Array): boolean;
getState(): unknown | undefined;
reset(): void;
isInitialized(): boolean;
getSequence(): number;
getLastReceivedSequence(): number;
processInput(input: unknown, timestamp?: number): number;
predictClientState(
currentState: unknown,
applyPhysics: (
state: unknown,
input: unknown,
deltaTime: number
) => unknown,
deltaTime: number
): unknown;
processServerUpdate(
serverState: unknown,
serverSequence: number,
serverTimestamp: number
): boolean;
reconcileState(
currentState: unknown,
applyPhysics: (
state: unknown,
input: unknown,
deltaTime: number
) => unknown
): unknown;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/bitpacker",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"tslib",
"zod",
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "istanbul",
},
},
});