push, render, event, repeat
The server-driven-UI loop
A server authors a view and ships it. A client renders it and sends events back. The server re-runs the view and ships the difference. The loop is small because Prism supplies its identity, its patch, and its codec.
The loop
The server holds the state and authors the view. The client holds the socket and draws what arrives. An event from the client mutates the state, the server re-runs view(state), and the difference travels back. The views converge because both sides speak Prism, and Prism gives the loop its identity and its patch for free.
server (Python authors view, C++ ships it) client (C++/WASM renders)
──────────────────────────────────────── ──────────────────────────
state: a Document socket opens
│ view(state) → a view-Document │ ← full snapshot on connect
│ SduiChannel.frame(view) │ setView → reconcile, draw
│ snapshot or sparse diff ───────────────────▶│
│ │ user presses a button
│◀─────────────── event-Document ────────────────│ SduiClient.sendEvent
│ mutate state, re-run view(state) │
│ ship the difference ───────────────────────────▶│ overlay onto running view
The server side is SduiChannel; the client side is SduiClient. Both are C++ in Kinogaki Platform. Python authors the view (a pure function) and orchestrates; the protocol mechanics are C++. The exact bytes are covered in the wire; this page is the loop and the properties that make it robust.
Path is the reconciliation key
Because Prism forbids UUIDs, an Element's Path is its identity. The client reconciles a new view against the old one by Path: a widget at the same Path and type is reused (its scroll and focus survive), a Path that vanished is dropped, a new Path is built. There are no keys to assign and no diff heuristics to tune. The model's own identity is the reconciliation key. This is the React-or-Flutter keyed-reconcile idea, except the key is the domain Path the server already uses, not something invented for the UI.
Per-client state
The server keeps one channel per connected client, and each channel holds that client's own revision counter and the last view-Document it sent (the diff base). One viewer's filter or resync never touches another's. The only value shared across clients is the server-launch epoch. This is what lets a slow or diverged client recover on its own without disturbing the rest.
Self-healing connection
The client transport is built to survive a flaky network without a reload:
- Reconnect with full-jitter backoff on any unclean close. A clean close (the app told it to go away) stays closed.
- Heartbeat. The client pings on an interval; if no frame arrives within a deadline (about two missed pings), it treats the socket as silently dead and reconnects.
- Visibility wake. A backgrounded tab freezes timers, so on becoming visible again the client decides immediately whether to reconnect rather than waiting for a tick.
- Outbox. Events produced while disconnected are queued and flushed, in order, on reconnect. Heartbeat pings are never queued.
On reconnect the server simply pushes a fresh full snapshot, the same thing a new client gets. There is no replay log to maintain, because a snapshot is always correct (see the oracle in the wire).
In-place reconcile, not rebuild
When a frame arrives, the client does not throw away the widget tree. WidgetView::setView reconciles: on a value-only change it mutates the existing widgets, so a ScrollView's offset and a focused text field survive; on a structural change it harvests the old tree into a Path-to-widget pool and rebuilds, reusing every widget whose Path and type held and dropping only what actually churned. A filter switch that replaces the card list keeps the scroll position and the composer's focus.
Why it stays small
The loop has no bespoke diffing, no UUID bookkeeping, no message schema beyond "a Document," and no replay buffer. Every one of those is absent because the model supplies it: Path is identity, overlay is the patch, a Document is the message. The next page makes the bytes concrete.