Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Renderoni is structured in 4 strict hierarchical layers:
- **L0: Deterministic Kernel (`src/core/`)**: Integer tick clock (`clock.ts`), seeded PRNG streams (`prng.ts`), dual-buffer transform pipeline (`transform-buffer.ts`), XXH3 state hashing (`hashing.ts`), resource ownership matrix (`ownership.ts`), and diagnostics (`diagnostics.ts`).
- **Rule 1**: NEVER call `Math.random()`, `Date.now()`, `performance.now()`, or `requestAnimationFrame()` inside simulation logic or entity updates. Always use `engine.prng` and `engine.clock.tick`.
- **Rule 2**: NEVER bypass the dual-buffer transform pipeline. Write physics transforms into canonical buffer slots, never directly into render scene graphs.
- **Rule 3**: NEVER import `@dimforge/rapier3d-compat` as a value outside `src/core/physics.ts` (`import type` is fine). Presets get the module from `ctx.native.rapier`, so `physics: false` bundles never include the Rapier WASM.
- **L1: Batteries & Subsystems (`src/presets/`, `src/animation/`, `src/audio/`, `src/vfx/`, `src/ui/`, `src/scene/`)**: High-level declarative presets (`body`, `sensor`, `light`, `kccPlayer`, `dynamicPlayer`, `proceduralModel`) and compact scene inventories for prompt → img2threejs factories.
- **L2: Agent Tooling & MCP (`src/mcp/`, `src/testing/`)**: Stdio Model Context Protocol server, custom Vitest matchers, and headless CLI verification.
- **L3: Web Application & Demos (`src/demo/`, `index.html`)**: Interactive playground and multi-archetype web showcases.
Expand Down Expand Up @@ -54,6 +55,7 @@ When connected over MCP (`bin/renderoni.js mcp`), use:
- **`act`**: Dispatch typed gameplay actions (`{ name: string, payload?: any }`).
- **`step`**: Advance simulation by $N$ fixed ticks.
- **`check`**: Run AST assertions.
- **World providers**: games with their own simulation register `engine.worlds.register({ name, describe?, observe?, resolve?, hash? })`; providers appear under `worlds` in `describe`, as `## <name>` sections in Tier 0 `observe`, as `world.<name>.<path>` in `check`, and in the state hash (provider-free hashes are unchanged).

---

Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ resize();
game.start();
```

Games that never use Rapier (their own simulation, no bodies or colliders) can opt out: `createRenderoni({ physics: false })` never loads the Rapier WASM, since Rapier is imported lazily at engine init, so bundlers split it into a separate chunk that is never fetched. `step()` skips the physics step, and Rapier-backed APIs (`native.world`, the `body`/`sensor`/`kccPlayer` presets, `mesh`/`model` with physics) fail with `RND_0412`.

---

## ⚡ CLI & Asset Generation
Expand Down Expand Up @@ -206,6 +208,39 @@ npx renderoni mcp
- **`step`**: Advance simulation by $N$ fixed ticks.
- **`check`**: Run AST assertions headlessly.

### World providers: games that run their own simulation

A game whose state lives in its own simulation (run as an `engine.systems` system) rather than in renderoni entities can still be driven by agents. Register a world provider and it shows up in every MCP tool:

```ts
import { createRenderoni, type WorldProvider } from 'renderoni';

const towns = [{ id: 'ashford', pop: 12 }];
const game = await createRenderoni({ mode: 'headless', seed: 7 });
game.systems.add({ update: () => { towns[0].pop++; } });

const sim: WorldProvider = {
name: 'sim', // 1-64 chars of A-Z a-z 0-9 _ -
describe: () => ({ towns }),
observe: (budgetBytes) => towns.map((t) => `${t.id}: pop ${t.pop}`),
resolve: ([kind, id, field]) =>
kind === 'towns' ? (towns.find((t) => t.id === id) as Record<string, unknown> | undefined)?.[field] : undefined,
hash: () => towns.map((t) => `${t.id}:${t.pop}`).join('|'),
};
const unregister = game.worlds.register(sim);

game.step(10);
game.check([{ op: 'greaterThan', path: 'world.sim.towns.ashford.pop', value: 20 }]);
unregister();
```

- **`describe`** adds `worlds: { <name>: describe() }` (only when a provider is registered).
- **`observe`** (Tier 0) appends a `## <name>` section per provider after the entity lines. Entities reserve up to half of the 500-byte budget, providers split the rest evenly and are called with their share, and bytes a provider leaves unused go back to the entities. Tier 1 deltas are entity-only.
- **`check`** resolves `world.<name>.<path...>` through `resolve(path)`; a missing provider or a provider without `resolve()` is reported as a failure.
- **`step`** / `getStateHash()` fold every `hash()` (string, finite number or `Uint8Array`) into the digest in name order. Games without providers hash exactly as before.

Invalid names and hooks fail with `RND_0410`, duplicate names with `RND_0411`, invalid hash values with `RND_0413`.

---

## 🚦 Feature Status
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"scripts": {
"dev": "vite",
"build": "tsup && npm run copy:editor-assets",
"prepare": "npm run build 1>&2",
"copy:editor-assets": "node -e \"require('fs').cpSync('src/editor/public','dist/editor/public',{recursive:true})\"",
"build:web": "vite build",
"preview": "vite preview",
Expand Down
73 changes: 60 additions & 13 deletions src/core/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@
*/

import * as THREE from 'three';
import RAPIER from '@dimforge/rapier3d-compat';
import type RAPIER from '@dimforge/rapier3d-compat';
import { SimulationClock, type SimulationClockOptions } from './clock.js';
import { PRNG } from './prng.js';
import { StructuralCommandQueue } from './commands.js';
import { DualBufferTransformPipeline } from './transform-buffer.js';
import { PhysicsEngine, type CollisionEvent, type SensorEvent } from './physics.js';
import { PhysicsEngine, type CollisionEvent, type RapierModule, type SensorEvent } from './physics.js';
import { StateHasher } from './hashing.js';
import { ResourceOwnershipTracker } from './ownership.js';
import { DiagnosticLogger } from './diagnostics.js';
import { WorldRegistry } from './worlds.js';
import { InputManager } from '../input/input-manager.js';
import { ActionRegistry } from '../input/actions.js';
import {
Expand Down Expand Up @@ -61,6 +62,12 @@ export interface RenderoniConfig {
subsystems?: Array<(engine: RenderoniEngine) => void>;
/** Opt-in play / win / lose / restart match loop. */
loop?: boolean | GameLoopOptions;
/**
* Set to `false` for games that never use Rapier: the WASM runtime is not
* loaded, `step()` skips the physics step and contact/sensor processing, and
* Rapier-backed APIs fail with RND_0412. Defaults to `true`.
*/
physics?: boolean;
}

export class EventEmitter {
Expand Down Expand Up @@ -164,6 +171,8 @@ export class SystemManager {
export class RenderoniEngine {
readonly mode: EngineMode;
readonly seed: number | string;
/** False when created with `physics: false`; Rapier is then never loaded. */
readonly physicsEnabled: boolean;

// L0 Deterministic Kernel Components
readonly clock: SimulationClock;
Expand All @@ -181,6 +190,8 @@ export class RenderoniEngine {
readonly input: InputManager;
readonly actions: ActionRegistry;
readonly loop: GameLoop;
/** Game-owned simulations exposed to MCP describe/observe/check and the state hash. */
readonly worlds: WorldRegistry;

// Native 3D Presentation Objects
readonly native: {
Expand All @@ -207,6 +218,7 @@ export class RenderoniEngine {
this.initialConfig = config;
this.mode = config.mode ?? 'headless';
this.seed = config.seed ?? 42;
this.physicsEnabled = config.physics !== false;
this.physics = new PhysicsEngine();

this.clock = new SimulationClock(config.clock);
Expand All @@ -222,6 +234,7 @@ export class RenderoniEngine {
this.input = new InputManager();
this.actions = new ActionRegistry();
this.loop = new GameLoop(config.loop);
this.worlds = new WorldRegistry(this.diagnostics, () => this.clock.tick);

if (this.loop.enabled) {
this.actions.register({ name: 'loop.start', handle: () => this.loop.start() });
Expand Down Expand Up @@ -249,13 +262,14 @@ export class RenderoniEngine {
renderer.shadowMap.enabled = true;
}

const physicsRef = this.physics;
const engine = this;
this.native = {
scene,
camera,
renderer,
get world() {
return physicsRef.world;
if (!engine.physicsEnabled) throw engine.physicsDisabledError('engine.native.world');
return engine.physics.world;
},
};
}
Expand Down Expand Up @@ -293,10 +307,12 @@ export class RenderoniEngine {

private async runInit(resolvedConfig: RenderoniConfig): Promise<void> {
await Promise.all([
this.physics.init({
gravity: resolvedConfig.gravity,
integrationParameters: { dt: this.clock.fixedDt },
}),
this.physicsEnabled
? this.physics.init({
gravity: resolvedConfig.gravity,
integrationParameters: { dt: this.clock.fixedDt },
})
: undefined,
this.hasher.init(),
]);

Expand All @@ -307,6 +323,23 @@ export class RenderoniEngine {
}
}

/**
* Error for a Rapier-backed API used on an engine created with
* `physics: false`, reported as an RND_0412 diagnostic.
*/
private physicsDisabledError(operation: string): Error {
const message =
`RND_0412: ${operation} is unavailable because this engine was created with physics: false. ` +
'Rapier was never loaded, so bodies, colliders and physics presets cannot be created.';
this.diagnostics.emit('RND_0412', message, {
severity: 'error',
tick: this.clock.tick,
remediation:
'Omit `physics` (or pass `physics: true`) to use Rapier, or build the entity without a body (plain EntityConfig, mesh/model with physics: "none").',
});
return new Error(message);
}

/** True once dispose() has run; disposed engines reject further simulation. */
get disposed(): boolean {
return this.isDisposed;
Expand Down Expand Up @@ -370,10 +403,19 @@ export class RenderoniEngine {
throw this.duplicateEntityIdError(requestedId);
}

// Physics engines keep failing fast when add() runs before init().
if (this.physicsEnabled) void this.native.world;
const engine = this;
const ctx: EntityContext = {
id: requestedId ?? generateId(),
native: {
world: this.native.world,
get world() {
return engine.native.world;
},
get rapier(): RapierModule {
if (!engine.physicsEnabled) throw engine.physicsDisabledError('ctx.native.rapier');
return engine.physics.rapier;
},
threeScene: this.native.scene,
},
events: {
Expand Down Expand Up @@ -892,9 +934,9 @@ export class RenderoniEngine {
ent.update?.(this.clock.fixedDt);
}

// 5. Step Rapier Physics World
// 5. Step Rapier Physics World (skipped entirely without physics)
const currentTick = this.clock.tick;
this.physics.step(
if (this.physicsEnabled) this.physics.step(
this.transformPipeline,
(contact: CollisionEvent) => {
this.events.emit(
Expand Down Expand Up @@ -922,7 +964,7 @@ export class RenderoniEngine {

// 7. Re-sync authoritative Rapier state so impulses, velocity writes and
// teleports applied by post-physics systems are canonical for this tick.
this.physics.syncCanonicalState(this.transformPipeline);
if (this.physicsEnabled) this.physics.syncCanonicalState(this.transformPipeline);

// 8. Periodically audit skipped bodies so a native move that Rapier
// cannot report can never become silent stale canonical state.
Expand Down Expand Up @@ -968,6 +1010,9 @@ export class RenderoniEngine {
* Bodies skipped by the canonical sync are audited first, so a native move
* Rapier cannot report is repaired and diagnosed instead of being hashed as
* stale state.
*
* Registered world providers that implement `hash()` are folded in by name
* order; without them the digest is byte-identical to a provider-free engine.
*/
getStateHash(): string {
if (!this.hasher.isReady) {
Expand All @@ -990,7 +1035,8 @@ export class RenderoniEngine {
return this.hasher.computeHash(
rawEntities,
this.transformPipeline.currentBuffer,
this.physics.getActiveContacts()
this.physics.getActiveContacts(),
this.worlds.digests()
);
}

Expand Down Expand Up @@ -1108,6 +1154,7 @@ export class RenderoniEngine {
guard(() => this.actions.clear());
guard(() => this.commands.clear());
guard(() => this.systems.clear());
guard(() => this.worlds.clear());
guard(() => {
const disposalErrors = this.reportResourceDisposalErrors(
'<engine>',
Expand Down
55 changes: 53 additions & 2 deletions src/core/hashing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
OFFSET_ANGVEL_Y,
OFFSET_ANGVEL_Z,
} from './transform-buffer.js';
import type { WorldDigest } from './worlds.js';

export const SCALE_Q12 = 4096.0; // 2^12

Expand Down Expand Up @@ -150,11 +151,16 @@ export class StateHasher {
*
* Throws when called before {@link StateHasher.init} resolves; a placeholder
* digest would make an uninitialized engine look deterministic.
*
* World provider digests are appended after the entity metadata in name
* order. With no digests nothing is appended, so games without providers
* hash exactly the bytes they always did.
*/
computeHash(
entities: StateEntityRecord[],
transformBuffer: Float32Array,
contacts: ContactPairRecord[] = []
contacts: ContactPairRecord[] = [],
worlds: WorldDigest[] = []
): string {
const h64 = this.h64;
if (!h64) {
Expand Down Expand Up @@ -249,15 +255,60 @@ export class StateHasher {
sortedEntities.map((entity) => `${entity.id}:${stableStringify(entity.state ?? {})}`).join('|')
);
const transformBytes = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
const bytes = new Uint8Array(transformBytes.length + metadata.length);
const worldBytes = encodeWorldDigests(worlds);
const bytes = new Uint8Array(transformBytes.length + metadata.length + worldBytes.length);
bytes.set(transformBytes);
bytes.set(metadata, transformBytes.length);
bytes.set(worldBytes, transformBytes.length + metadata.length);
const hashBigInt = h64(bytes);
return '0x' + hashBigInt.toString(16).padStart(16, '0');
}

}

/**
* Encodes world digests as `\0worlds` followed, per provider in code-unit
* name order, by `\0<name>\0<tag><byteLength>\0<bytes>` where the tag is
* `s` (UTF-8 string), `n` (little-endian float64) or `b` (raw bytes). The
* explicit lengths keep adjacent providers from aliasing each other.
*/
function encodeWorldDigests(worlds: WorldDigest[]): Uint8Array {
if (worlds.length === 0) return new Uint8Array(0);

const encoder = new TextEncoder();
const sorted = [...worlds].sort((a, b) => compareCodeUnits(a.name, b.name));
const parts: Uint8Array[] = [encoder.encode('\0worlds')];

for (const { name, digest } of sorted) {
let tag: string;
let payload: Uint8Array;
if (typeof digest === 'string') {
tag = 's';
payload = encoder.encode(digest);
} else if (typeof digest === 'number') {
if (!Number.isFinite(digest)) {
throw new Error(`RND_0413: world provider "${name}" returned a non-finite hash value ${digest}.`);
}
tag = 'n';
payload = new Uint8Array(8);
new DataView(payload.buffer).setFloat64(0, digest, true);
} else {
tag = 'b';
payload = digest;
}
parts.push(encoder.encode(`\0${name}\0${tag}${payload.length}\0`), payload);
}

const total = parts.reduce((sum, part) => sum + part.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}

function quantizeChecked(value: number | undefined, entityId: string, label: string): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error(
Expand Down
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './ownership.js';
export * from './diagnostics.js';
export * from './observations.js';
export * from './loop.js';
export * from './worlds.js';
export * from './engine.js';

export interface EntityRecord {
Expand Down
Loading