Pingerchips LogoPingerchips
Wire Protocol

Spaces wire protocol

Cursors, member presence, location tracking, and distributed locks — over a pure-relay ephemeral channel.

Spaces has no channel of its own. It runs on a PubSub room: channel with the ephemeral- prefix:

Topic: app:{appKey}:room:ephemeral-{spaceId}

Ephemeral mode: no envelope, no serial, no replay, no WAL. Every event is a straight broadcast_from! from the publisher to all other members. Missed events while disconnected are gone.

The ephemeral- prefix is how the current server selects pure-relay mode. It is stable but treat it as an implementation detail — a future version makes "no durability" a channel-config flag.


Join

["1", "1", "app:pk_live_x:room:ephemeral-doc-42", "phx_join", {}]

No auth needed unless the app has enable_user_authentication = 1, in which case the join carries a capability token granting channel:subscribe:ephemeral-doc-42.

Join reply

{ "status": "ok", "response": { "socket_id": "abc-123" } }

On join the server tracks this connection in Phoenix.Tracker and pushes the current member set once:

presence:state (once, on join)

["1", null, "app:pk_live_x:room:ephemeral-doc-42", "presence:state", {
  "members": [
    { "id": "sock-1", "client_id": "user-42", "joined_at": 1738500000000 }
  ]
}]

Known gap. The server sends presence:state on join but does not currently broadcast live presence:join / presence:leave when other members come and go. The member list is accurate as of your join only. Cursor / location / lock events do flow live. This is the driver for the presence redesign — the SDK already listens for the diffs the server needs to start sending.


Client → server

Any event name that isn't lock:* is relayed verbatim to every other member. The SDK uses these conventions:

cursor

{ "client_id": "user-42", "position": { "x": 124, "y": 88 } }

High-frequency. The SDK throttles to ~30 fps (throttle option, default 33 ms). The server does not rate-limit cursor events beyond the app's max_client_events_per_sec.

location

{ "client_id": "user-42", "location": { "elementId": "heading-2" } }

What the member is looking at / editing.

presence:update

{ "client_id": "user-42", "profile": { "name": "Ada", "color": "#ffe500" } }

The SDK sends this on space.enter(profile) and again on any profile change. Relayed to other members as-is.

presence:leave

{ "client_id": "user-42" }

Sent on space.leave() before the channel phx_leave.

lock:acquire

{ "id": "block-3" }

Routed through the space's SpaceWorker GenServer (serialised — the only non-relay path). Reply:

{ "status": "ok", "response": { "id": "block-3", "status": "locked" } }

or { "status": "error", "response": { "reason": "lock held by another member" } }.

lock:release

{ "id": "block-3" }

Reply { "status": "ok" }. Locks are also auto-released when the holder's socket disconnects.


Server → client

cursor / location / presence:update / presence:leave

Relayed verbatim from another member — same shapes as the pushes above, always carrying the originating client_id.

lock:update

Broadcast by SpaceWorker on every lock state change (acquire, release, disconnect-release):

["1", null, "app:pk_live_x:room:ephemeral-doc-42", "lock:update", {
  "id": "block-3",
  "status": "locked" | "unlocked",
  "holder": "sock-7" | null
}]

Lock semantics

  • One lockId string → one holder at a time. Scoped to the space.
  • Acquire is first-come-first-served; no queue — contenders retry at the app layer with backoff.
  • Auto-released on holder disconnect (SpaceWorker detects the dropped socket).
  • No TTL.
  • SpaceWorker idles out after inactivity; the next lock:acquire restarts it.

SDK

PingerchipsSpaces (in pingerchips-js) wraps all of this:

import PingerchipsSpaces from 'pingerchips-js/spaces';

const spaces = new PingerchipsSpaces('pk_live_x');
const space  = await spaces.get('doc-42', { clientId: 'user-42', profile: { name: 'Ada' } });

space.members.subscribe('enter', (m) => …);   // once presence diffs ship
space.cursors.set({ x, y });
space.cursors.subscribe('update', ({ member, position }) => …);
space.locations.set({ elementId: 'heading-2' });
await space.locks.acquire('block-3');

See Spaces SDK reference.

On this page