@its-not-rocket-science/ananke
v0.1.16
Published
Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)
Maintainers
Readme
Ananke — Programmer's Guide
Package:
@its-not-rocket-science/anankeFull project overview:docs/project-overview.md
What is Ananke?
Ananke is a deterministic, physics-grounded simulation engine for characters, combat, and survivability. It models entities using real physical quantities — mass in kg, force in newtons, energy in joules — rather than abstract hit points or dice rolls.
Same seed + same inputs → identical results, every time. No floating-point drift. Suitable for lockstep multiplayer, reproducible research, and offline AI training.
Installation
npm install @its-not-rocket-science/anankeRequires Node ≥ 18. ESM-only. TypeScript declarations included — no @types/ package
needed. Zero runtime dependencies.
Versioning: pin to a specific version in production. The
0.xseries may include minor-version breaking changes to Tier 2 (experimental) APIs; Tier 1 (Stable) APIs follow full semver. SeeSTABLE_API.mdfor the tier breakdown anddocs/versioning.mdfor the upgrade policy.
Core concepts
Fixed-point arithmetic
All simulation values use Q — a fixed-point integer where SCALE.Q = 16384 represents
1.0. Never use raw number for simulation values; always use q() to construct them.
import { q, SCALE } from "@its-not-rocket-science/ananke";
const half = q(0.50); // 8192 — 50%
const full = q(1.00); // 16384 — 100%
const eighty = q(0.80); // 13107 — 80%
// SI unit scales
SCALE.m; // 1000 — 1 metre in fixed-point units
SCALE.kg; // 1000 — 1 kilogram
SCALE.mps; // 1000 — 1 m/s
SCALE.J; // 1 — 1 joule (energy is stored at 1:1)You will see values like position_m: { x: 3000, y: 0, z: 0 } — that is 3 metres on the
x-axis (3000 / SCALE.m = 3). The _m, _kg, _J, _s suffixes on field names tell
you the unit.
The Entity
An Entity is any simulated object. Required fields at creation:
import type { Entity } from "@its-not-rocket-science/ananke";| Field | Type | Meaning |
|---|---|---|
| id | number | Unique integer; used as RNG salt |
| teamId | number | Entities attack those on different teams |
| position_m | Vec3 | World-space position in fixed-point metres |
| attributes | IndividualAttributes | Physical stats (force, power, mass…) |
| energy | { current_J, max_J } | Stamina pool in joules |
| injury | InjuryState | Per-region damage accumulation |
| condition | ConditionSnapshot | Shock, fear, fatigue |
| loadout | { items: Item[] } | Equipped weapons and armour |
Use a factory instead of constructing these manually — see Quick starts below.
The simulation loop
import { mkWorld, stepWorld } from "@its-not-rocket-science/ananke";
const world = mkWorld(seed, entities); // create world with deterministic seed
for (let tick = 0; tick < 2000; tick++) {
const commands = buildCommands(world); // your AI / player input
stepWorld(world, commands, ctx); // mutates world in-place
}stepWorld is the only function that mutates state. Everything else is pure computation.
Call it at 20 Hz for real-time simulation; 1 Hz or lower for campaign-scale time.
Quick start A — Melee combat
Two fighters, one fight, three seeds:
import {
mkWorld, stepWorld, generateIndividual, q,
SCALE, STARTER_WEAPONS, STARTER_ARMOUR,
buildAICommands, buildWorldIndex, buildSpatialIndex,
AI_PRESETS,
} from "@its-not-rocket-science/ananke";
import type { Q } from "@its-not-rocket-science/ananke";
const LONGSWORD = STARTER_WEAPONS[2]!;
const LEATHER = STARTER_ARMOUR[0]!;
function makeEntity(id: number, teamId: number, x_m: number) {
const e = generateIndividual("KNIGHT_INFANTRY", id, teamId);
e.position_m = { x: x_m * SCALE.m, y: 0, z: 0 };
e.loadout = { items: [LONGSWORD, LEATHER] };
return e;
}
const policy = AI_PRESETS["lineInfantry"]!;
for (const seed of [1, 42, 99]) {
const a = makeEntity(1, 1, -2);
const b = makeEntity(2, 2, +2);
const world = mkWorld(seed, [a, b]);
const ctx = { tractionCoeff: q(0.85) as Q };
let tick = 0;
while (tick < 2000 && !a.injury.dead && !b.injury.dead) {
tick++;
const idx = buildWorldIndex(world);
const spat = buildSpatialIndex(world, 40_000);
const cmds = buildAICommands(world, idx, spat, () => policy);
stepWorld(world, cmds, ctx);
}
const winner = a.injury.dead ? "B" : b.injury.dead ? "A" : "draw";
console.log(`seed=${seed} winner=${winner} ticks=${tick}`);
}Reading injury state
for (const [region, inj] of Object.entries(entity.injury.regions)) {
const pct = (inj.surfaceDamage / SCALE.Q * 100).toFixed(0);
if (inj.surfaceDamage > 0)
console.log(` ${region}: ${pct}% surface damage${inj.infected ? " [infected]" : ""}`);
}
console.log(` dead: ${entity.injury.dead}`);
console.log(` shock: ${(entity.condition.shockQ / SCALE.Q * 100).toFixed(0)}%`);Using the narrative layer
import {
CollectingTrace, renderChronicle,
} from "@its-not-rocket-science/ananke";
const trace = new CollectingTrace();
stepWorld(world, commands, { ...ctx, trace });
const log = renderChronicle(trace.events, world.entities, { verbosity: "normal" });
console.log(log);
// → "Knight strikes Brawler in the torso for 340 J. Brawler staggers."Quick start B — Campaign and world simulation
Advance two polities through 90 days with tech diffusion and emotional contagion:
import {
createPolityRegistry, stepPolityDay,
applyEmotionalContagion, stepTechDiffusion,
createEmotionalWave, FEAR_WAVE, q, SCALE,
} from "@its-not-rocket-science/ananke";
const WORLD_SEED = 1;
const registry = createPolityRegistry([
{ id: 1, name: "Ironhold", population: 50_000, techEra: 2, moraleQ: q(0.70) /* ... */ },
{ id: 2, name: "Ashfeld", population: 30_000, techEra: 1, moraleQ: q(0.55) /* ... */ },
]);
const pairs = [{ polityA: 1, polityB: 2, routeQuality_Q: q(0.60), atWar: false /* ... */ }];
for (let day = 1; day <= 90; day++) {
stepPolityDay(registry, WORLD_SEED, day);
stepTechDiffusion(registry, pairs, WORLD_SEED, day);
applyEmotionalContagion(registry, [createEmotionalWave(FEAR_WAVE, 1)], pairs);
}
for (const p of registry.polities) {
console.log(`${p.name}: pop=${p.population} era=${p.techEra} morale=${(p.moraleQ / SCALE.Q).toFixed(2)}`);
}Quick start C — Species and character generation
Generate individuals from a body-plan archetype, apply aging, and describe them:
import {
generateIndividual, applyAgingToAttributes,
describeCharacter, formatCharacterSheet,
} from "@its-not-rocket-science/ananke";
// Generate a 45-year-old knight
const base = generateIndividual("KNIGHT_INFANTRY", 1, 1);
const aged = applyAgingToAttributes(base.attributes, 45);
console.log(formatCharacterSheet({ ...base, attributes: aged }));
// → Strength: 1840 N [above average]
// Reaction: 0.21 s [average]
// ...
// Fantasy species
const elf = generateIndividual("ELF_ARCHER", 2, 2);
console.log(describeCharacter(elf));Available built-in archetypes: KNIGHT_INFANTRY, PRO_BOXER, GRECO_WRESTLER,
AMATEUR_BOXER, LARGE_PACIFIC_OCTOPUS, and all species defined in
src/species.ts — humans, elves, dwarves, orcs, dragons,
Vulcans, Klingons, and more.
The command system
stepWorld takes a CommandMap — a Map<entityId, EntityCommand>. You build it
manually, from your AI layer, or from the built-in AI system:
import type { EntityCommand } from "@its-not-rocket-science/ananke";
// Attack
const commands = new Map<number, EntityCommand>([
[entityId, { kind: "attack", targetId: opponentId, weapon: LONGSWORD }],
]);
// Move to a position
commands.set(entityId, {
kind: "move",
destination: { x: 5 * SCALE.m, y: 0, z: 0 },
});
// Treat a wounded ally
commands.set(medicId, {
kind: "treat",
targetId: woundedId,
schedule: { care: "field_surgery", equipmentTier: 2 },
});Valid kind values: "attack", "move", "grapple", "treat", "use_capability",
"signal", "idle".
Determinism
Ananke guarantees that mkWorld(seed, entities) followed by identical commands produces
identical WorldState at every tick, regardless of platform, JS engine, or execution time.
Rules to preserve determinism in your host:
- Never use
Math.random()— usemakeRng(eventSeed(...))from the package instead - Iterate
world.entitiesin insertion order (it is a stable array, not a Map) - Keep entity
idvalues stable across ticks — IDs are used as RNG salts - Do not rely on wall-clock time inside the simulation loop
import { makeRng, eventSeed } from "@its-not-rocket-science/ananke";
// Deterministic RNG inside your AI or event code:
const rng = makeRng(eventSeed(world.seed, world.tick, entityId, 0, 42));
const roll = rng(); // float in [0, 1) — deterministic from inputsReplay and serialisation
import {
ReplayRecorder, serializeReplay, deserializeReplay, replayTo,
} from "@its-not-rocket-science/ananke";
// Record
const recorder = new ReplayRecorder();
for (let tick = 0; tick < N; tick++) {
const cmds = buildCommands(world);
recorder.record(tick, cmds);
stepWorld(world, cmds, ctx);
}
const json = serializeReplay(recorder.replay); // stable JSON string
// Replay to any tick
const replay = deserializeReplay(json);
const state = replayTo(replay, initialWorld, targetTick, ctx);3D renderer bridge
Extract per-segment pose data for driving a humanoid rig at renderer frame rate:
import {
extractRigSnapshots, deriveAnimationHints, BridgeEngine,
} from "@its-not-rocket-science/ananke";
// Per-tick: get bone transforms
const snapshots = extractRigSnapshots(world.entities, bodyPlan);
// snapshots[entityId] → RigSnapshot { segments: Map<segmentId, { position_m, rotation }> }
// Per-tick: get animation state machine hints
const hints = deriveAnimationHints(entity);
// hints → { idle, walk, run, attacking, prone, unconscious, dead, shockQ, fearQ, ... }
// Or use BridgeEngine for double-buffered interpolation at renderer frame rate:
const bridge = new BridgeEngine(config);
bridge.writeSimFrame(world.tick, world.entities);
const interp = bridge.readInterpolated(rendererTimestamp);See docs/bridge-contract.md for the full double-buffer
protocol and AnimationHints field-by-field contract.
API stability tiers
| Tier | Guarantee | Examples |
|------|-----------|---------|
| Tier 1 — Stable | Breaking changes require major semver bump + migration guide | stepWorld, generateIndividual, Entity, q, SCALE, bridge module |
| Tier 2 — Experimental | May change in minor versions; CHANGELOG will note it | Campaign, polity, dialogue, faction, quest subsystems |
| Tier 3 — Internal | No stability guarantee; may change at any time | makeRng, eventSeed, kernel tuning constants, mkHumanoidEntity |
Full tier table: STABLE_API.md
TypeScript
The package ships full .d.ts declarations. Key types to know:
import type {
Entity, // the simulated object
WorldState, // world.entities + world.tick + world.seed
KernelContext, // tractionCoeff, weather, etc. — passed to stepWorld
EntityCommand, // what an entity does this tick
IndividualAttributes, // physical stats (SI units)
InjuryState, // per-region damage
ConditionSnapshot, // shock, fear, fatigue
Q, // fixed-point number alias (just `number` at runtime)
Vec3, // { x, y, z } in fixed-point metres
} from "@its-not-rocket-science/ananke";Q is a nominal alias for number — it carries no runtime overhead, but the q()
constructor and SCALE constants make the intent clear in every formula.
Performance guidance
| Scenario | Recommended tick rate | Practical entity cap | |---|---|---| | Duel / 1v1 | 20 Hz | Unlimited | | Skirmish (squads) | 20 Hz | ~300 | | Battle (formations) | 10 Hz | ~500 | | Siege / campaign | 1 Hz | ~1 000 | | World simulation | 0.01 Hz (once/day) | ~10 000 |
Enable buildSpatialIndex when entities exceed ~50 and distances matter. Disable
expensive subsystems (disease O(n²) spread, thermoregulation) at high entity counts
unless required.
Full benchmark methodology and operational guide: docs/performance.md
Validation and trust
Ananke's outputs are validated against historical and experimental sources:
- Isolated sub-system validation — compares physical constants against sport-science
and biomechanics datasets:
npm run run:validation - Emergent validation — four historical combat scenarios (du Picq, Keegan, Lanchester,
Raudzens) across 100 seeds each:
npm run run:emergent-validation - Pinned baseline — committed result summaries that CI guards against regression:
docs/emergent-validation-report.md
Further reading
| Document | What's in it |
|---|---|
| docs/host-contract.md | Stable integration surface — everything needed to embed Ananke without reading src/ |
| docs/integration-primer.md | Data-flow diagrams, type glossary, gotchas |
| docs/bridge-contract.md | 3D renderer bridge protocol (AnimationHints, GrapplePoseConstraint) |
| STABLE_API.md | Full tier table for every export |
| docs/versioning.md | Semver policy, breaking-change tiers, upgrade cadence |
| docs/performance.md | Benchmark results, operational guide, entity caps |
| docs/emergent-validation-report.md | Historical scenario validation report |
| docs/project-overview.md | Full project overview — implementation status, entity model reference, design principles, architecture |
