Operations

See Architecture for how operations are applied to the scene graph.

All operations follow the pattern: dtype.verb. Operations marked as LWW use Last-Write-Wins semantics based on Lamport time. Additive operations accumulate regardless of order. This can become a problem when you divide a single atomic operation into two -- for instance for text.replace, dividing it into text.insert and text.delete will result into the buffer removing the first, since delete takes precedence when the two share the same index.

Schema-less Design

Vuer-RTC is schema-less—type information is embedded in the operation itself via the ot field. No separate schema definition is required. The ot string (e.g., 'vector3.add') is parsed to extract:

  • dtype: The data type (vector3)
  • operation: The merge behavior (add)

This enables dynamic properties without upfront schema definition.

Operation Format

All operations are flat (no nested objects) except for node operations that send node data. Common fields:

interface BaseOp {
  ot: string;        // Operation type: "dtype.verb"
  key?: string;         // Target node key ("." = root, default)
  path?: string;        // Property path on the node ("." = node root)
  value?: unknown;      // Value to apply (type depends on ot)
  // Additional flat fields per operation type:
  // index, from, to, alpha, separator, toPath, etc.
}

Examples:

// Set position
{ ot: 'vector3.set', key: 'player-1', path: 'position', value: [1, 2, 3] }

// Blend color with alpha
{ ot: 'color.blend', key: 'sky', path: 'color', value: '#00ff00', alpha: 0.5 }

// Move array item
{ ot: 'array.move', key: 'item', path: 'tags', from: 0, to: 2 }

// Move node to new parent
{ ot: 'node.move', key: 'scene', path: 'children', value: { nodeKey: 'cube-1', newParent: 'group-1' } }

// Node insert (nested value for node data)
{ ot: 'node.insert', key: '.', path: 'children', value: { key: 'cube-1', tag: 'Mesh' } }

Text CRDT Operations

Text operations use a compressed schema for efficient wire transfer. All text operations support both position-based format (local edits) and CRDT format (network sync with conflict resolution metadata).

Compressed Schema

The compressed schema uses short field names and tuple encoding to minimize payload size:

Field names:

  • ot — Operation type (full name, e.g., 'insert', 'text.insert')
  • id — Unique item ID for inserts (e.g., 'alice:5')
  • value — For inserts/replaces: [anchor, content] tuple
  • rm — For deletes/replaces: array of [itemId, length] tuples
  • seq — Lamport sequence number
  • ts — Timestamp in seconds

Rope CRDT Operations

Standalone TextRope operations (no key/path — used internally):

// Insert
{
  ot: 'insert',
  id: 'alice:5',
  value: ['alice:4', 'hello'],  // [anchor, content]
  seq: 100,
  ts: 1234567890.123
}

// Delete
{
  ot: 'delete',
  rm: [['alice:5', 3], ['alice:10', 2]]  // [[itemId, length], ...]
}

// Replace (atomic delete + insert)
{
  ot: 'replace',
  rm: [['alice:5', 3], ['bob:7', 1]],    // deletions
  id: 'alice:8',
  value: ['alice:7', 'world'],            // [anchor, content]
  seq: 101,
  ts: 1234567890.456
}

Graph Text Operations

Text operations on graph node properties (have key/path):

// text.insert - Insert text with CRDT metadata
{
  ot: 'text.insert',
  key: 'node-1',
  path: 'description',
  id: 'alice:5',
  value: ['alice:4', 'hello'],  // [anchor, content]
  seq: 100,
  ts: 1234567890.123
}

// text.delete - Delete text spans
{
  ot: 'text.delete',
  key: 'node-1',
  path: 'description',
  rm: [['alice:5', 3], ['alice:10', 2]]
}

// text.replace - Atomic delete + insert
{
  ot: 'text.replace',
  key: 'node-1',
  path: 'description',
  rm: [['alice:5', 3]],
  id: 'alice:8',
  value: ['alice:7', 'world'],  // [anchor, content]
  seq: 101,
  ts: 1234567890.456
}

// text.init - Initialize CRDT text property
{
  ot: 'text.init',
  key: 'node-1',
  path: 'description',
  value: 'initial text'
}

Position-Based Format

For local edits, you can use position-based format. The operation handlers automatically convert to CRDT format:

// Insert at position (auto-converted to CRDT)
{
  ot: 'text.insert',
  key: 'node-1',
  path: 'description',
  position: 5,
  value: 'hello'
}
// → Converted to CRDT format with id, anchor, seq, ts

// Delete at position
{
  ot: 'text.delete',
  key: 'node-1',
  path: 'description',
  position: 5,
  length: 3
}
// → Converted to CRDT format with rm spans

// Replace at position
{
  ot: 'text.replace',
  key: 'node-1',
  path: 'description',
  position: 5,
  length: 3,
  value: 'new text'
}
// → Converted to CRDT format with rm spans and insert metadata

Item IDs

Item IDs use the format agentId:seq where:

  • agentId — Session/agent identifier (e.g., 'alice', 'session-abc123')
  • seq — Local sequence number (increments per character inserted)

Example: 'alice:42' means the 42nd character inserted by agent alice.

YATA Ordering

The CRDT uses YATA (Yet Another Transformation Approach) for conflict resolution:

  • Each character has a unique ID and parent reference (anchor)
  • Insertions are ordered by: sequence number → timestamp → ID
  • Concurrent inserts at the same position resolve deterministically

Number

TypeDescriptionExample
number.setSet numeric value (LWW){ ot: "number.set", key: "light-1", path: "intensity", value: 0.5 }
number.addAdd to numeric value (additive){ ot: "number.add", key: "player", path: "score", value: 10 }
number.multiplyMultiply numeric value{ ot: "number.multiply", key: "sprite", path: "scale", value: 2 }
number.minSet to min(current, value){ ot: "number.min", key: "enemy", path: "health", value: 0 }
number.maxSet to max(current, value){ ot: "number.max", key: "enemy", path: "health", value: 100 }

Vector3

TypeDescriptionExample
vector3.setSet position/scale (LWW){ ot: "vector3.set", key: "cube", path: "position", value: [1, 2, 3] }
vector3.addAdd to vector (additive){ ot: "vector3.add", key: "player", path: "position", value: [0, 1, 0] }
vector3.multiplyComponent-wise multiply{ ot: "vector3.multiply", key: "mesh", path: "scale", value: [2, 2, 2] }
vector3.applyEulerRotate by euler angles (radians){ ot: "vector3.applyEuler", key: "arrow", path: "direction", value: [0, 1.57, 0], order: "YXZ" }
vector3.applyQuaternionRotate by quaternion{ ot: "vector3.applyQuaternion", key: "arrow", path: "direction", value: [0, 0.7, 0, 0.7] }

Euler

TypeDescriptionExample
euler.setSet euler angles (LWW){ ot: "euler.set", key: "camera", path: "rotation", value: [0, 1.57, 0] }
euler.addAdd to euler angles (additive){ ot: "euler.add", key: "turret", path: "rotation", value: [0.1, 0, 0] }

Quaternion

TypeDescriptionExample
quaternion.setSet rotation (LWW){ ot: "quaternion.set", key: "bone", path: "rotation", value: [0, 0, 0, 1] }
quaternion.multiplyCompose rotations{ ot: "quaternion.multiply", key: "joint", path: "rotation", value: [0, 0.7, 0, 0.7] }

Color

TypeDescriptionExample
color.setSet hex color (LWW){ ot: "color.set", key: "material-1", path: "color", value: "#ff0000" }
color.blendBlend towards color{ ot: "color.blend", key: "sky", path: "color", value: "#00ff00", alpha: 0.5 }

String

For move operations, to refers to the current index (before the move), not the post-move index. Negative indices count from the end.

"Hello World" → move [0:5] to index 6" WorldHello"
"Hello World" → move [0:5] to index -1" WorldHello"
TypeDescriptionExample
string.setSet string value (LWW){ ot: "string.set", key: "label", path: "text", value: "Player 1" }
string.concatAppend to string{ ot: "string.concat", key: "console", path: "log", value: "event", separator: "\n" }
string.insertInsert at index{ ot: "string.insert", key: "label", path: "text", value: "Hello ", index: 0 }
string.cutRemove substring{ ot: "string.cut", key: "label", path: "text", from: 0, to: 5 }
string.replaceReplace substring{ ot: "string.replace", key: "label", path: "text", from: 0, to: 5, value: "Hi" }
string.moveMove substring (to = current index){ ot: "string.move", key: "label", path: "text", from: 0, to: 5, index: 10 }

Boolean

TypeDescriptionExample
boolean.setSet boolean (LWW){ ot: "boolean.set", key: "mesh", path: "visible", value: true }
boolean.orOR operation{ ot: "boolean.or", key: "state", path: "dirty", value: true }
boolean.andAND operation{ ot: "boolean.and", key: "button", path: "enabled", value: false }
boolean.xorXOR operation (toggle){ ot: "boolean.xor", key: "light", path: "on", value: true }

Array

For move operations, to refers to the current index (before the move), not the post-move index. Negative indices count from the end.

["a", "b", "c", "d"] → move index 0 to index 2["b", "c", "a", "d"]
["a", "b", "c", "d"] → move index 0 to index -2["b", "c", "a", "d"]
TypeDescriptionExample
array.setReplace array (LWW){ ot: "array.set", key: "item", path: "tags", value: ["a", "b"] }
array.pushAppend item{ ot: "array.push", key: "item", path: "tags", value: "new-tag" }
array.insertInsert at index{ ot: "array.insert", key: "item", path: "tags", value: "tag", index: 1 }
array.removeRemove by value or index{ ot: "array.remove", key: "item", path: "tags", value: "old-tag", index: 2 }
array.moveMove item (to = current index){ ot: "array.move", key: "item", path: "tags", from: 0, to: 2 }
array.unionAdd unique items{ ot: "array.union", key: "item", path: "tags", value: ["x", "y"] }

Object

TypeDescriptionExample
object.setReplace object (LWW){ ot: "object.set", key: "entity", path: "metadata", value: { a: 1 } }
object.updateShallow merge{ ot: "object.update", key: "entity", path: ".", value: { visible: true } }
object.removeRemove key from object{ ot: "object.remove", key: "entity", path: "metadata.tempKey" }

Node

Structural operations for the scene graph.

TypeDescriptionExample
node.insertInsert child node under parent{ ot: "node.insert", key: ".", path: "children", value: { key: "cube-1", tag: "Mesh" } }
node.removeDelete node (tombstone){ ot: "node.remove", key: "cube-1", path: "." }
node.upsertInsert or merge if exists{ ot: "node.upsert", key: "scene", path: "children", value: { key: "cube-1", position: [1,2,3] } }
node.insetInsert or set if exists{ ot: "node.inset", key: "scene", path: "children", value: { key: "cube-1", tag: "Mesh", visible: true } }
node.moveMove node to new parent{ ot: "node.move", key: "scene", path: "children", value: { nodeKey: "cube-1", newParent: "group-1" } }

Message Format

Operations are wrapped in a CRDTMessage that includes metadata for conflict resolution:

interface CRDTMessage {
  id: string;        // Unique message ID
  client: string;    // Client session ID
  clock: VectorClock;// Vector clock for causality
  lt: number;        // Lamport time for total ordering
  ts: number;        // Wall-clock timestamp
  ops: Operation[];  // Batch of operations
}

Euler Rotation Order

The vector3.applyEuler operation supports two optional parameters:

  • order: Rotation order - 'XYZ' (default), 'YXZ', 'ZXY', 'ZYX', 'YZX', 'XZY'
  • intrinsic: true (default) for intrinsic rotation, false for extrinsic

Intrinsic vs. Extrinsic Rotations

  • Intrinsic (default): Axes move with the body. Rotate around body's X axis, then around the new Y axis, then around the new Z axis.
  • Extrinsic: Axes stay fixed in space. Rotate around fixed X, then fixed Y, then fixed Z.

Note: Intrinsic XYZ is equivalent to Extrinsic ZYX (reversed order).

// Intrinsic YXZ rotation (common for cameras/FPS)
{ ot: 'vector3.applyEuler', key: 'camera', path: 'direction', value: [pitch, yaw, roll], order: 'YXZ' }

// Extrinsic XYZ rotation (fixed-axis)
{ ot: 'vector3.applyEuler', key: 'arm', path: 'direction', value: [x, y, z], intrinsic: false }

Common Use Cases

OrderUse Case
XYZDefault, general 3D rotations
YXZFirst-person cameras (yaw-pitch-roll)
ZYXAircraft/aerospace conventions
ZXYSome motion capture systems

Try it live: See operations in action in the Live Demo.