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]tuplerm— For deletes/replaces: array of[itemId, length]tuplesseq— Lamport sequence numberts— 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 metadataItem 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
| Type | Description | Example |
|---|---|---|
number.set | Set numeric value (LWW) | { ot: "number.set", key: "light-1", path: "intensity", value: 0.5 } |
number.add | Add to numeric value (additive) | { ot: "number.add", key: "player", path: "score", value: 10 } |
number.multiply | Multiply numeric value | { ot: "number.multiply", key: "sprite", path: "scale", value: 2 } |
number.min | Set to min(current, value) | { ot: "number.min", key: "enemy", path: "health", value: 0 } |
number.max | Set to max(current, value) | { ot: "number.max", key: "enemy", path: "health", value: 100 } |
Vector3
| Type | Description | Example |
|---|---|---|
vector3.set | Set position/scale (LWW) | { ot: "vector3.set", key: "cube", path: "position", value: [1, 2, 3] } |
vector3.add | Add to vector (additive) | { ot: "vector3.add", key: "player", path: "position", value: [0, 1, 0] } |
vector3.multiply | Component-wise multiply | { ot: "vector3.multiply", key: "mesh", path: "scale", value: [2, 2, 2] } |
vector3.applyEuler | Rotate by euler angles (radians) | { ot: "vector3.applyEuler", key: "arrow", path: "direction", value: [0, 1.57, 0], order: "YXZ" } |
vector3.applyQuaternion | Rotate by quaternion | { ot: "vector3.applyQuaternion", key: "arrow", path: "direction", value: [0, 0.7, 0, 0.7] } |
Euler
| Type | Description | Example |
|---|---|---|
euler.set | Set euler angles (LWW) | { ot: "euler.set", key: "camera", path: "rotation", value: [0, 1.57, 0] } |
euler.add | Add to euler angles (additive) | { ot: "euler.add", key: "turret", path: "rotation", value: [0.1, 0, 0] } |
Quaternion
| Type | Description | Example |
|---|---|---|
quaternion.set | Set rotation (LWW) | { ot: "quaternion.set", key: "bone", path: "rotation", value: [0, 0, 0, 1] } |
quaternion.multiply | Compose rotations | { ot: "quaternion.multiply", key: "joint", path: "rotation", value: [0, 0.7, 0, 0.7] } |
Color
| Type | Description | Example |
|---|---|---|
color.set | Set hex color (LWW) | { ot: "color.set", key: "material-1", path: "color", value: "#ff0000" } |
color.blend | Blend 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"| Type | Description | Example |
|---|---|---|
string.set | Set string value (LWW) | { ot: "string.set", key: "label", path: "text", value: "Player 1" } |
string.concat | Append to string | { ot: "string.concat", key: "console", path: "log", value: "event", separator: "\n" } |
string.insert | Insert at index | { ot: "string.insert", key: "label", path: "text", value: "Hello ", index: 0 } |
string.cut | Remove substring | { ot: "string.cut", key: "label", path: "text", from: 0, to: 5 } |
string.replace | Replace substring | { ot: "string.replace", key: "label", path: "text", from: 0, to: 5, value: "Hi" } |
string.move | Move substring (to = current index) | { ot: "string.move", key: "label", path: "text", from: 0, to: 5, index: 10 } |
Boolean
| Type | Description | Example |
|---|---|---|
boolean.set | Set boolean (LWW) | { ot: "boolean.set", key: "mesh", path: "visible", value: true } |
boolean.or | OR operation | { ot: "boolean.or", key: "state", path: "dirty", value: true } |
boolean.and | AND operation | { ot: "boolean.and", key: "button", path: "enabled", value: false } |
boolean.xor | XOR 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"]| Type | Description | Example |
|---|---|---|
array.set | Replace array (LWW) | { ot: "array.set", key: "item", path: "tags", value: ["a", "b"] } |
array.push | Append item | { ot: "array.push", key: "item", path: "tags", value: "new-tag" } |
array.insert | Insert at index | { ot: "array.insert", key: "item", path: "tags", value: "tag", index: 1 } |
array.remove | Remove by value or index | { ot: "array.remove", key: "item", path: "tags", value: "old-tag", index: 2 } |
array.move | Move item (to = current index) | { ot: "array.move", key: "item", path: "tags", from: 0, to: 2 } |
array.union | Add unique items | { ot: "array.union", key: "item", path: "tags", value: ["x", "y"] } |
Object
| Type | Description | Example |
|---|---|---|
object.set | Replace object (LWW) | { ot: "object.set", key: "entity", path: "metadata", value: { a: 1 } } |
object.update | Shallow merge | { ot: "object.update", key: "entity", path: ".", value: { visible: true } } |
object.remove | Remove key from object | { ot: "object.remove", key: "entity", path: "metadata.tempKey" } |
Node
Structural operations for the scene graph.
| Type | Description | Example |
|---|---|---|
node.insert | Insert child node under parent | { ot: "node.insert", key: ".", path: "children", value: { key: "cube-1", tag: "Mesh" } } |
node.remove | Delete node (tombstone) | { ot: "node.remove", key: "cube-1", path: "." } |
node.upsert | Insert or merge if exists | { ot: "node.upsert", key: "scene", path: "children", value: { key: "cube-1", position: [1,2,3] } } |
node.inset | Insert or set if exists | { ot: "node.inset", key: "scene", path: "children", value: { key: "cube-1", tag: "Mesh", visible: true } } |
node.move | Move 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,falsefor 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
| Order | Use Case |
|---|---|
XYZ | Default, general 3D rotations |
YXZ | First-person cameras (yaw-pitch-roll) |
ZYX | Aircraft/aerospace conventions |
ZXY | Some motion capture systems |
Try it live: See operations in action in the Live Demo.