Skip to main content

vuer-rtc

CRDT-based real-time collaborative data structures for Python.

A Python port of the TypeScript @vuer-ai/vuer-rtc library. Multiple clients can concurrently edit a shared scene graph and all changes converge automatically — no manual conflict resolution required.

Install

pip install vuer-rtc

Or install from source with test dependencies:

pip install -e ".[test]"

Quick start

from vuer_rtc import create_graph

# Create a client store
store = create_graph("client-1", on_send=lambda msg: send_to_server(msg))

# Build a scene
store.edit({
    "ot": "node.insert",
    "key": "",            # parent key ("" = root)
    "path": "children",
    "value": {"key": "scene", "tag": "Scene", "name": "My Scene"},
})
store.edit({
    "ot": "node.insert",
    "key": "scene",
    "path": "children",
    "value": {"key": "cube", "tag": "Mesh", "position": [0, 1, 0], "opacity": 1.0},
})
store.commit("create scene")

# Edit properties
store.edit({"ot": "vector3.set", "key": "cube", "path": "position", "value": [3, 2, 1]})
store.edit({"ot": "number.set",  "key": "cube", "path": "opacity",  "value": 0.5})
store.commit("move and fade cube")

# Read state
graph = store.get_state().graph
print(graph.nodes["cube"].get_property("position"))  # [3, 2, 1]

Two-client example

from vuer_rtc import create_graph

messages_a, messages_b = [], []

store_a = create_graph("alice", on_send=lambda msg: messages_a.append(msg))
store_b = create_graph("bob",   on_send=lambda msg: messages_b.append(msg))

# Alice creates a node
store_a.edit({
    "ot": "node.insert",
    "key": "",
    "path": "children",
    "value": {"key": "obj", "tag": "Mesh", "health": 100},
})
msg = store_a.commit("create obj")

# Bob receives Alice's message
store_b.receive(msg)

# Both edit concurrently
store_a.edit({"ot": "number.add", "key": "obj", "path": "health", "value": -25})
msg_a = store_a.commit("damage")

store_b.edit({"ot": "number.add", "key": "obj", "path": "health", "value": -10})
msg_b = store_b.commit("poison")

# Exchange messages
store_a.receive(msg_b)
store_b.receive(msg_a)

# Both converge: 100 + (-25) + (-10) = 65
assert store_a.get_state().graph.nodes["obj"].get_property("health") == 65
assert store_b.get_state().graph.nodes["obj"].get_property("health") == 65

Operations

Every operation is a dict with ot, key, path, and typically value.

Number

{"ot": "number.set", "key": "n", "path": "score",   "value": 42}       # LWW
{"ot": "number.add", "key": "n", "path": "counter",  "value": 1}       # additive
{"ot": "number.multiply", "key": "n", "path": "scale", "value": 2}     # multiplicative
{"ot": "number.min", "key": "n", "path": "cooldown", "value": 5}       # min
{"ot": "number.max", "key": "n", "path": "health",   "value": 0}       # max

Vector3 / Quaternion / Euler

{"ot": "vector3.set", "key": "n", "path": "position",  "value": [1, 2, 3]}
{"ot": "vector3.add", "key": "n", "path": "velocity",  "value": [0, 1, 0]}

{"ot": "quaternion.set",      "key": "n", "path": "rotation", "value": [0, 0, 0, 1]}
{"ot": "quaternion.multiply", "key": "n", "path": "rotation", "value": [0, 0.707, 0, 0.707]}

{"ot": "euler.set", "key": "n", "path": "rotation", "value": [0, 1.57, 0]}
{"ot": "euler.add", "key": "n", "path": "rotation", "value": [0, 0.1, 0]}

String / Boolean / Color

{"ot": "string.set",  "key": "n", "path": "name",    "value": "Cube"}
{"ot": "string.concat","key": "n", "path": "log",     "value": " line2", "separator": "\n"}

{"ot": "boolean.set", "key": "n", "path": "visible", "value": True}
{"ot": "boolean.or",  "key": "n", "path": "dirty",   "value": True}     # True wins
{"ot": "boolean.and", "key": "n", "path": "locked",  "value": False}    # False wins

{"ot": "color.set",   "key": "n", "path": "color",   "value": "#ff0000"}
{"ot": "color.blend",  "key": "n", "path": "color",  "value": "#0000ff"}  # averages

Array / Object

{"ot": "array.set",    "key": "n", "path": "items", "value": [1, 2, 3]}
{"ot": "array.push",   "key": "n", "path": "items", "value": 4}
{"ot": "array.remove",  "key": "n", "path": "items", "value": 2}
{"ot": "array.union",   "key": "n", "path": "tags",  "value": ["a", "b"]}

{"ot": "object.set",   "key": "n", "path": "config", "value": {"debug": True}}
{"ot": "object.merge",  "key": "n", "path": "config", "value": {"verbose": True}}  # deep merge

Node (scene graph structure)

# Insert a child node
{"ot": "node.insert", "key": "parent", "path": "children",
 "value": {"key": "child", "tag": "Mesh", "name": "Child"}}

# Remove (soft-delete / tombstone)
{"ot": "node.remove", "key": "parent", "path": "children", "value": "child"}

# Move a node to a new parent
{"ot": "node.move", "key": "old-parent", "path": "children",
 "value": {"nodeKey": "child", "newParent": "new-parent"}}

Text (collaborative CRDT text)

# Initialize a text property
{"ot": "text.init",   "key": "doc", "path": "content"}

# Insert text at a position. `value` is an [anchor, content] tuple; use None
# for the anchor on a position-based local insert (the CRDT computes it).
{"ot": "text.insert",  "key": "doc", "path": "content", "position": 0, "value": [None, "Hello"]}

# Delete a range
{"ot": "text.delete",  "key": "doc", "path": "content", "position": 0, "length": 5}

# Atomic delete + insert (for select-and-type)
{"ot": "text.replace", "key": "doc", "path": "content", "position": 0, "length": 5, "value": [None, "Hi"]}

Important: Use text.replace instead of separate text.delete + text.insert when replacing a selection. The edit buffer deduplicates by key:path, so a delete followed by an insert on the same key and path will lose the delete.

Undo / redo

store.edit({"ot": "number.set", "key": "n", "path": "x", "value": 10})
store.commit("set x")

store.undo()  # x reverts to previous value
store.redo()  # x = 10 again

Undo/redo is journal-based. Each undo() marks a journal entry as deleted and replays the remaining entries. This means undo works correctly across concurrent edits from multiple clients.

Edit buffer

Edits are buffered until commit(). Additive operations on the same key:path are merged automatically:

store.edit({"ot": "vector3.add", "key": "n", "path": "pos", "value": [1, 0, 0]})
store.edit({"ot": "vector3.add", "key": "n", "path": "pos", "value": [0, 1, 0]})
store.edit({"ot": "vector3.add", "key": "n", "path": "pos", "value": [0, 0, 1]})

# Only one op in the buffer: value = [1, 1, 1]
assert len(store.get_state().edits.ops) == 1

store.commit("combined move")

Use store.cancel() to discard uncommitted edits and revert to the pre-edit graph.

Receiving remote messages

store.receive(msg)      # apply a CRDTMessage from the server
store.ack(msg_id)       # mark one of our messages as server-acknowledged

Duplicate messages are automatically ignored (idempotent).

Retry & compaction

from vuer_rtc import get_unacked_messages

# Retry unacknowledged messages (e.g. after reconnect)
for msg in get_unacked_messages(store.get_state()):
    send_to_server(msg)

# Compact acknowledged journal entries into a snapshot
store.compact()

Conflict resolution

Merge strategy Operation types
Last-Write-Wins (LWW) *.set — highest Lamport timestamp wins
Additive number.add, vector3.add, quaternion.multiply — values accumulate
Commutative boolean.or / boolean.and, number.min / number.max, array.union
Deep merge object.merge — recursive per-key merge
CRDT text text.insert / text.delete — RGA/YATA algorithm, order-independent

All strategies are deterministic: given the same set of operations (in any order), every client converges to the same state.

Architecture

GraphStore
  ├── ClientState
  │     ├── graph: SceneGraph        # current computed scene
  │     ├── journal: [JournalEntry]  # committed ops (with ack status)
  │     ├── edits: EditBuffer        # uncommitted ops
  │     ├── snapshot: Snapshot       # compacted checkpoint
  │     └── vector_clock / lamport_time
  │
  ├── edit(op) → optimistic apply + buffer
  ├── commit() → journal entry + CRDTMessage out
  ├── receive(msg) → journal entry + rebuild graph
  ├── undo() / redo() → meta ops + rebuild
  └── compact() → snapshot from acked entries

State transitions are pure functions (on_edit, commit_edits, on_remote_message, etc.) wrapped by the GraphStore class for convenience. You can use either the class or the bare functions depending on your architecture.

Running tests

pip install -e ".[test]"
pytest tests/ -x -q

To skip slow benchmarks:

pytest tests/ -x -q -m "not slow"

Lossless text checkpoints (0.0.2)

When constructing Snapshot from a TypeScript server response, retain its textRopes field along with the graph, vector clock, Lamport time, and journal index. createGraph(initial_snapshot=...) and GraphStore.fromServer(...) automatically hydrate this metadata; hydrateTextSnapshot(snapshot) supports custom consumers. Python uses its existing _textCrdt.<path> rope plus visible string representation. The metadata preserves TS character IDs and deleted anchors; msgpack serialize/deserialize forwards it unchanged.

Do not reconstruct a live CRDT checkpoint from plain strings or remove textRopes in a bridge. Legacy checkpoints remain readable but cannot recover discarded identities. New hydration accepts both Python snake_case and TypeScript camelCase rope fields. The first local insertion after loading a peer's checkpoint switches to the local agent's character IDs.

The server's additive sync-check / sync-status messages can be encoded as ordinary wire dictionaries. Compare the SHA-256 body checksum only at matching committed clocks, after pending edits are acknowledged. Slow checks or different clocks indicate catch-up, not proven divergence. Python does not automatically run the application-level checksum monitor or manage held drafts.

Metadata

Release files for vuer-rtc 0.0.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for vuer-rtc 0.0.2
File Size Uploaded
vuer_rtc-0.0.2.tar.gz 67.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for vuer-rtc 0.0.2
File Interpreter ABI Platform
vuer_rtc-0.0.2-py3-none-any.whl Python 3 none any Details

Total release size: 114.1 kB

Release files / vuer_rtc-0.0.2.tar.gz

Download URL vuer_rtc-0.0.2.tar.gz
Size 67.7 kB
Tags Source
SHA-256 checksum
How to use checksums
c402788a3ccf6e0d864c6e878c11828fa6cb928f56fef45eecbe0b092bd56a91
BLAKE2b-256 checksum
How to use checksums
d27e030576a3bed8ac3b5e3189401cd40ef966c43d92df7c974f7aae2e5f7da6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.7

Release files / vuer_rtc-0.0.2-py3-none-any.whl

Download URL vuer_rtc-0.0.2-py3-none-any.whl
Size 46.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e6c9bcd16c90326248bd19be5cf1c92cabb60d38a6f6751a3526da91fa60bbc3
BLAKE2b-256 checksum
How to use checksums
5f60b389e9cd220e1f6e07ac24260340cebe0abb5f3ef33526946edd40c0207c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.7

Release history Release notifications | RSS feed

This release

0.0.2 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page