Skip to content

Spec: Loro CRDT World State Integration

TODO-FEAT-003 | Priority 3 | Pillar B Foundation Date: 2026-03-21 Status: SPEC DRAFT

Context

What Exists

  • @holoscript/crdt package: DID-JWT signing, LWWRegister, ORSet, GCounter, WebRTC P2P sync, RBAC conflict resolver
  • @holoscript/crdt-spatial package: Already uses Loro v1.0.0! Strategy C hybrid rotation (base quaternion LWW + delta Euler counters + 30s checkpoints), LoroWebSocketProvider, useSpatialSync() React hook
  • CRDTRoomTrait.ts: Trait integration for CRDT rooms
  • WeatherSystem.ts: Event-based state changes (potential CRDT consumer)

What's Missing

  • World-level state model: Objects, terrain heightmaps, NPC memory, inventory — only rotation/position currently in Loro
  • Persistence: No server-side snapshot storage — state is ephemeral per session
  • Scale testing: Only tested with 2 clients
  • Server authority: No server-side validation for physics disagreements
  • Publishing pipeline: No Loro snapshot → CDN → Hololand registry flow
  • Time-travel: Loro supports it natively but not exposed in HoloScript

Design

Architecture: Extend crdt-spatial, Don't Replace

Since @holoscript/crdt-spatial already uses Loro, we extend it with world-level state types rather than creating a new package.

State Model

typescript
// packages/crdt-spatial/src/WorldState.ts

import { Loro, LoroMap, LoroList, LoroTree, LoroMovableList } from 'loro-crdt';

/**
 * World state hierarchy in Loro document:
 *
 * root (LoroMap)
 * ├── objects (LoroMap<string, ObjectState>)     — Scene objects (LWW per field)
 * ├── terrain (LoroList<number>)                 — Heightmap (append-only erosion deltas)
 * ├── npc_memory (LoroTree)                      — Hierarchical NPC knowledge
 * ├── inventory (LoroMap<string, LoroMap>)        — Per-player inventories
 * ├── weather (LoroMap)                          — Weather state (LWW)
 * └── meta (LoroMap)                             — World metadata, version, permissions
 */

export class WorldStateDoc {
  private doc: Loro;

  constructor(peerId?: string) {
    this.doc = new Loro();
    if (peerId) this.doc.setPeerId(BigInt(peerId));
  }

  // --- Object Management (LWW Map) ---

  setObject(id: string, state: ObjectState): void;
  getObject(id: string): ObjectState | null;
  removeObject(id: string): void;
  getAllObjects(): Map<string, ObjectState>;

  // --- Terrain (Heightmap via List) ---

  setTerrainHeight(x: number, z: number, height: number): void;
  getTerrainHeight(x: number, z: number): number;
  applyErosionDelta(x: number, z: number, delta: number): void;
  getHeightmap(): Float32Array;

  // --- NPC Memory (Tree) ---

  addNPCMemory(npcId: string, memory: NPCMemoryEntry): void;
  queryNPCMemories(npcId: string, limit?: number): NPCMemoryEntry[];

  // --- Inventory (Per-player Map) ---

  setInventoryItem(playerId: string, slot: string, item: InventoryItem): void;
  getInventory(playerId: string): Map<string, InventoryItem>;

  // --- Persistence ---

  export(): Uint8Array; // Binary snapshot
  import(data: Uint8Array): void; // Restore from snapshot
  exportUpdates(since: Uint8Array): Uint8Array; // Incremental updates

  // --- Time Travel ---

  checkpoint(label?: string): string; // Returns version ID
  getHistory(): VersionEntry[];
  rollback(versionId: string): void;

  // --- Sync ---

  onUpdate(callback: (event: LoroEvent) => void): void;
  merge(other: WorldStateDoc): void;
}

export interface ObjectState {
  position: [number, number, number];
  rotation: [number, number, number, number]; // Quaternion
  scale: [number, number, number];
  mesh: string; // Asset reference
  traits: string[]; // Applied traits
  owner: string; // DID of creator
  properties: Record<string, unknown>;
}

export interface NPCMemoryEntry {
  timestamp: number;
  type: 'observation' | 'interaction' | 'emotion' | 'fact';
  content: string;
  embedding?: Float32Array; // For vector search
  decay: number; // 0-1, decreases over time
}

export interface InventoryItem {
  type: string;
  quantity: number;
  metadata: Record<string, unknown>;
}

Two-Tier Sync (W.156)

CRITICAL: Do NOT use Loro for physics particle sync.

Tier 1: Loro CRDT (high-level state)
├── Object positions/rotations (updated at ~10Hz, not 60Hz)
├── Terrain heightmap (updated on erosion events, ~0.1Hz)
├── NPC memory (updated on interaction events)
├── Inventory changes (updated on trade/pickup events)
└── Weather state (updated by @weather hub, ~1Hz)

Tier 2: Raw Binary WebRTC (physics particles)
├── Fluid particle positions (60Hz, 28 bytes/particle)
├── Cloth vertex positions (60Hz, 12 bytes/vertex)
├── Debris fragments (60Hz, 12 bytes/fragment)
└── Spatially filtered: only send within player's view radius

Persistence Layer

typescript
// packages/crdt-spatial/src/WorldPersistence.ts

export class WorldPersistence {
  private storageUrl: string; // Railway persistent volume or S3

  constructor(config: PersistenceConfig);

  // Auto-save: snapshot every N seconds or on significant change
  startAutoSave(intervalMs: number): void;
  stopAutoSave(): void;

  // Manual save/load
  saveSnapshot(worldId: string, doc: WorldStateDoc): Promise<string>; // Returns version ID
  loadSnapshot(worldId: string, version?: string): Promise<WorldStateDoc>;
  listVersions(worldId: string): Promise<VersionEntry[]>;

  // Publishing pipeline integration
  exportForPublish(worldId: string): Promise<PublishBundle>;
}

export interface PublishBundle {
  snapshot: Uint8Array; // Loro binary
  assets: AssetManifest; // Referenced meshes, textures
  metadata: WorldMetadata; // Name, description, creator DID
  version: string;
}

Integration with Existing crdt-spatial

Extend the existing useSpatialSync() hook:

typescript
// Existing: position/rotation sync only
const { position, rotation } = useSpatialSync(objectId);

// Extended: full world state
const { worldState, setObject, checkpoint, rollback } = useWorldState(worldId);

Files Changed

FileAction
packages/crdt-spatial/src/WorldState.tsNEW — World state document class
packages/crdt-spatial/src/WorldPersistence.tsNEW — Snapshot persistence
packages/crdt-spatial/src/types.tsAdd ObjectState, NPCMemoryEntry, InventoryItem types
packages/crdt-spatial/src/index.tsExport new modules
packages/crdt-spatial/package.jsonBump version, add loro-crdt dep if not already
packages/core/src/traits/WorldStateTrait.tsNEW — @world_state trait handler

Test Targets

TestTargetMethod
10K objects sync<100ms convergence, 2 clientsIntegration test with 2 Loro docs
Snapshot export/importRound-trip fidelityUnit test: set state → export → import → verify
Time-travel rollbackCorrect state restorationUnit test: set state → checkpoint → modify → rollback → verify
Terrain erosionPersistent heightmap changesIntegration: apply erosion → save → reload → verify heights
NPC memoryPersistent across sessionsIntegration: add memory → save → reload → query

Dependencies

  • loro-crdt (already in crdt-spatial) — may need version bump to 1.8+
  • Consumed by: TODO-FEAT-004 (@weather persistence), TODO-FEAT-005 (WebRTC physics sync), TODO-FEAT-010 (economy/publishing), TODO-FEAT-011 (terrain erosion)

Risks

  1. Loro version compatibility: crdt-spatial uses v1.0.0, research recommends v1.8+. Need to check API changes.
  2. Heightmap size: 1024×1024 terrain = 4MB as Float32Array. Loro handles this but snapshot size matters for network. Mitigation: Use incremental updates, not full snapshots.
  3. MV-Transformer for rotation: Already implemented in crdt-spatial (Strategy C). Validate it handles >2 peers.

References

  • ArXiv 2503.17826 — MV-Transformer pattern
  • Loro CRDT docs: https://loro.dev/docs
  • GAPS Research: W.156 (Two-Tier State Sync), G.GAPS.07 (CRDT particle anti-pattern)

Released under the MIT License.