Appearance
P0-C3: Yrs / CRDT Basics
Taught: 2026-04-24 (during Phase 0 Bootstrap) Milestone: Phase 0 — Bootstrap Result: PASS Backfilled: 2026-05-04 — original prose was scattered across the early planning session; reconstructed into the corrected conversational format
Why this concept matters here
Phase 0 stores sphere.radius (and eventually all scene state) in a Yrs CRDT document. This is a "from day 1" architectural decision: even though Phase 1 is single-user, building on Yrs from the start means future multiplayer collab is additive, not a rewrite. Understanding the Doc → Transaction → Map → set/get pattern is what makes SceneDoc::set_sphere_radius() and subscribe_radius() possible.
The walkthrough
Step 1: What a CRDT is, in one sentence
A CRDT (Conflict-free Replicated Data Type) is a data structure where concurrent writes from multiple users merge automatically without conflict. No locks, no "last writer wins," no manual merge resolution. The structure itself encodes how to combine divergent edits.
Concrete example: User A and User B are both editing a shared document. A writes "X = 5" at 3:00:00.001. B writes "X = 7" at 3:00:00.002. Their network is partitioned briefly. When they reconnect, the CRDT can deterministically resolve to "X = 7" (or whatever the rule is) without any coordination — both clients arrive at the same answer.
This is not "trivial last-write-wins." Real CRDTs handle inserts in a list (Y inserts at position 5, X inserts at position 5 simultaneously — both insertions are kept, ordered by some rule), text editing (concurrent edits to the same paragraph merge into a coherent document), etc.
CRDTs are how Figma, Notion, modern Google Docs, and collaborative whiteboards work. They're also how this engine plans to do collab later.
Step 2: Why this engine uses Yrs even in single-user mode
Yrs is the Rust port of Yjs, the most popular CRDT library. It runs in single-user mode just fine — no network, no peers — and exposes a normal-looking key-value API. The "from day 1" reasoning:
If you build the renderer assuming "scene state is just a struct in memory" and then later want multiplayer, you have to refactor every scene-state access to go through CRDT. That's a lot of code.
If you build the renderer assuming "scene state is in a Yrs Doc" from day one, then adding multiplayer later is just "open a websocket and sync the doc" — zero changes to renderer code. The single-user "tax" is small (a handful of extra function calls per scene mutation); the future-multiplayer payoff is "no rewrite needed."
This was a locked Phase 1 decision (revisited and softened in May 2026, but the sphere is already in Yrs).
Step 3: The data model — Doc, Map, transactions
Yrs's main data structures:
Doc— the top-level container. Holds all the data. There is one Doc per "document" you care about (in this engine: one Doc holds the whole scene).MapRef— a key-value map inside a Doc. You get one by name:doc.get_or_insert_map("sphere"). Multiple maps can live in one Doc, each addressed by a name.- Transactions — every read and every write goes through a transaction. Reads use
doc.transact()(immutable). Writes usedoc.transact_mut()(mutable). Transactions are how Yrs batches changes and tracks them for sync.
The basic write looks like:
rust
let doc = Doc::new();
let sphere = doc.get_or_insert_map("sphere");
let mut txn = doc.transact_mut();
sphere.insert(&mut txn, "radius", 1.5_f64); // sets sphere.radius = 1.5
// txn drops here; the write is committedThe basic read:
rust
let txn = doc.transact();
let radius = sphere.get(&txn, "radius")
.and_then(|v| v.cast::<f64>().ok())
.unwrap_or(1.0);The Yrs-stored value is f64 (CRDTs prefer wider types for numeric precision); the renderer converts to f32 when handing it to the GPU.
Step 4: Subscriptions — how observers fire on change
A renderer that lives next to a CRDT doc wants to know when the doc changes — so it can re-render. Yrs supports subscriptions: register a callback, fire on any mutation.
rust
let _sub = sphere.observe(move |txn, event| {
// `event.keys(txn)` is a HashMap<Arc<str>, EntryChange>
if let Some(change) = event.keys(txn).get("radius") {
match change {
EntryChange::Inserted(v) | EntryChange::Updated(_, v) => {
let new_radius = v.clone().cast::<f64>().ok().map(|f| f as f32);
// do something with new_radius
}
EntryChange::Removed(_) => { /* ... */ }
}
}
});observe returns a Subscription (an opaque Arc<dyn Drop>). As long as you hold it, the callback fires on every change. Drop the subscription, and the callback stops.
For the engine's SceneDoc::subscribe_radius(cb), this means: any code (the renderer, a future debug overlay, a future undo system) can subscribe to "tell me when sphere.radius changes" without polling. The Yrs Doc is the single source of truth.
Step 5: Why this maps cleanly to multiplayer later
In single-user Yrs:
- One
Docin memory - Read/write through transactions
- Observers fire on change
In multiplayer Yrs:
- One
Docper peer - Each peer's writes generate "update" messages (binary diffs)
- Updates are sent over the network (websocket, WebRTC, anything)
- Each peer applies remote updates to their local Doc
- Observers fire as if the writes were local
The KEY property: the Doc API doesn't change. Your SceneDoc::set_sphere_radius() works identically in single-user and multiplayer modes. The networking is bolted on; the data model is unchanged. That's the "from day 1" win.
The mental model in one sentence
A Yrs Doc is a CRDT-backed key-value store: you read/write through transactions, observers fire on change, and the API is identical whether you're running solo or syncing with peers — so building on Yrs from day 1 means multiplayer is later additive, not a rewrite.
Explain-back question
Suppose we hadn't used Yrs and instead stored
sphere.radiusas a plainf32field in a Rust struct. Two questions:
- What part of the the engine's code would have to change when we later add multiplayer?
- What does "transaction" buy us in single-user mode that a plain field doesn't?
User's answer (PASS)
We are going with Yrs now which is a CRDT - content replication strategy. So that we don't have to do a major rewrite when adding multiplayer in Phase 3. The doc holds maps, transactions wrap reads/writes, observers fire on change. Single-user is just "no peers" — same Doc, same API.
Judgment
PASS. Got:
- CRDT purpose — multi-user state convergence ✓
- "From day 1" reasoning — avoid the rewrite ✓
- API surface — Doc / Map / Transaction / Observer ✓
- Single-user is just multiplayer with no peers — the key insight that the API doesn't change ✓
Slight terminology slip: he said "content replication" but CRDT is "conflict-free replicated data type." Same idea, less precise wording. Not load-bearing.