Skip to content

State model

liebstoeckel keeps several distinct pieces of state, each with its own owner, transport, and authority. This page is the map.

liebstoeckel state handling

On load the engine reads window.__LIEBSTOECKEL_LIVE__ (a <script> the live server injects):

  • absent → standalone. The deck is a plain .html. Nav syncs between the presenter window and the audience window over BroadcastChannel (same machine only). Plugins render their fallback (no shared state).
  • present → live. The deck connected to a live server. Nav + plugin state live in a shared Yjs document synced over WebSocket, so they cross devices. Each client has a role (presenter / viewer) from its URL token.

The same built .html works both ways. See Live presenting.

StateOwnerTransportAuthorityPersistence
Slide index / step / totalDeck / PresenterView controllerlive: Yjs deck map · standalone: BroadcastChannelpresenter writes, everyone readsephemeral (per session)
Plugin shared state (e.g. poll votes)the plugin, via pluginState()Yjs plugin:<id> map (CRDT), or plugin:<id>:<instance> for a named instanceany participant writes (incl. read-only viewers); presenter-only actions gated in plugin codeephemeral (lost on server restart)
Brand (data-brand)Deck / PresenterViewper-window React statelocaln/a
Elapsed timerPresenterViewper-window React statelocalresets per window
Participant idclientsessionStoragelocalsurvives reload (1 browser session = 1 participant)
Overlays (help / blur / overview / QR / jump buffer)Deckper-window React statelocal UIn/a

The two nav controllers (same shape, different transport)

Section titled “The two nav controllers (same shape, different transport)”

Deck and PresenterView both pick a controller and call next() / prev() / setIndex() on it:

  • standalone → useDeckSync(count): BroadcastChannel carries { index, step, total, startedAt }. A newly opened window broadcasts request; the others reply, so it snaps to the live state.
  • live → useLiveDeck(doc, count, canDrive): a Yjs deck map holds { index, step, total }. Viewers (canDrive = false) can’t write, so they follow; the presenter drives. next()/prev() read the freshest doc state (no stale-closure on rapid presses) and apply step/slide logic.

In both, steps reveal before the slide advances; the presenter view shows the step counter, the audience just sees the reveals.

Plugin state is a CRDT, so writes merge and there’s no central lock. We assume non-malicious clients: the server does basic checks only and drops oversized/garbage frames, but does not police which field a client writes. “Presenter-only” semantics (e.g. close-voting) are enforced in the plugin’s own UI/logic by checking role, not by the transport.

  • Reconnect: connectLive auto-reconnects with capped exponential backoff and re-pushes local state on reopen. A clean network blip recovers without a reload.
  • Half-open detection: the server sends a keepalive (~25 s) and Bun pings idle sockets; the client runs a watchdog and force-reconnects if no frame arrives within staleMs. A silently-dead (mobile-sleep / NAT-timeout) socket recovers instead of hanging.
  • Bad frames: every Y.applyUpdate boundary (client message, server Hub.recv) is wrapped, and the server caps inbound frame size. One malformed update can’t disrupt the session.
  • Broadcast isolation: the relay guards each per-peer send; a failing peer is dropped rather than starving the broadcast to the others.
  • Late join: the server sends the full Yjs state to any newcomer, so opening a link mid-session lands you on the current slide with current plugin state.
  • Live presenting: sessions, tokens, roles, the windows, and controller selection.
  • Relay: taking a session to the public internet without shipping deck code to the relay.
  • Plugins overview and State & sync: authoring shared state, fallback, and theming.

Comments

liebstoeckel

Code-first presentations your agent can author. One file out, no server.

© 2026 Leon Kaucher