Skip to content

Real-Time Subscriptions

How the admin panel receives live data from SpacetimeDB without polling.


Overview

The admin panel maintains a persistent WebSocket connection to SpacetimeDB for read-only subscriptions. This connection provides live updates to all panel pages — when a ban is created, a player connects, or an admin action is logged, the panel updates instantly.

Connection Architecture

graph TD
    Browser

    subgraph StdbProvider["StdbProvider (React context)"]
        WS["WebSocket → SpacetimeDB (port 3000)"]
        Sub["Subscribe to 6 platform tables"]
        SA["Receive SubscribeApplied (initial rows)"]
        TU["Receive TransactionUpdate (incremental changes)"]
    end

    subgraph UseTable["useTable‹T› hook"]
        Insert["onInsert → add to React state"]
        Update["onUpdate → replace in React state"]
        Delete["onDelete → remove from React state"]
    end

    subgraph UseCluster["useClusterState() hook"]
        RQ["React Query (10s stale, 30s polling for computed state)"]
    end

    Browser --> StdbProvider
    Browser --> UseTable
    Browser --> UseCluster

StdbProvider

The StdbProvider component initialises the SpacetimeDB connection on mount:

// lib/stdb-provider.tsx
function StdbProvider({ children }) {
  useEffect(() => {
    const conn = new SpacetimeDBClient(STDB_HOST, MODULE_NAME);
    conn.subscribe([
      'SELECT * FROM server_config',
      'SELECT * FROM ban_entry',
      'SELECT * FROM whitelist_entry',
      'SELECT * FROM admin_role',
      'SELECT * FROM panel_session',
      'SELECT * FROM panel_audit_log',
    ]);
    conn.connect();
  }, []);
  return <StdbContext.Provider value={conn}>{children}</StdbContext.Provider>;
}

useTable Hook

Generic hook that converts SpacetimeDB table callbacks into React state:

function useTable<T>(tableName: string): T[] {
  const [rows, setRows] = useState<T[]>([]);

  useEffect(() => {
    const table = connection.db.getTable(tableName);
    setRows([...table]);

    table.onInsert((row) =>
      setRows(prev => [...prev, row]));
    table.onUpdate((oldRow, newRow) =>
      setRows(prev => prev.map(r => r === oldRow ? newRow : r)));
    table.onDelete((row) =>
      setRows(prev => prev.filter(r => r !== row)));

    return () => { /* cleanup subscriptions */ };
  }, [tableName]);

  return rows;
}

Update Flow

1. Admin clicks "Ban Player" in panel
2. POST /api/ban → API Gateway → panel_ban_player reducer
3. Reducer creates BanEntry row in STDB
4. STDB pushes TransactionUpdate to ALL connected WebSocket clients
5. Panel's BanEntry subscription receives onInsert callback
6. useTable updates React state → components re-render
7. Dashboard ban feed, Players page ban status, all update instantly

Key insight: The panel that triggered the action AND every other connected panel session see the update simultaneously. No polling, no manual refresh.

Connection Guard

The ConnectionGuard component wraps pages that require an active STDB connection:

State Display
Connecting Spinner with "Connecting to SpacetimeDB..."
Connected Page content
Disconnected Warning with reconnect button
Error Error message with details

BSATN Protocol

The WebSocket connection uses the SpacetimeDB v2 binary protocol with BSATN (Binary SpacetimeDB Algebraic Type Notation) encoding:

Message Direction Description
Subscribe Client → Server SQL queries declaring interest
SubscribeApplied Server → Client Initial rows matching queries
TransactionUpdate Server → Client Incremental changes (inserts + deletes)
ReducerResult Server → Client Outcome of reducer calls