Skip to content

(Network) PresenceSync<T> #530

Description

@fraxken

Proposal: PresenceSync<T>

Status: proposal, nothing implemented yet.

Scope: @jolly-pixel/network, with @jolly-pixel/three,
@jolly-pixel/pixel-draw.renderer and @jolly-pixel/ui as consumers.

The same ~120 lines of presence plumbing exist four times in the monorepo,
with only the payload and the paint target differing.

What is duplicated

PeerFrustumSync (three) and PixelCursorSync (pixel-draw) are close to
line-for-line twins: same private field names, same method names, same order.

Mechanic PeerFrustumSync PixelCursorSync PeerPresenceGhostSync RoomPresenceSource
3 to 4 room listeners plus symmetric off in destroy() yes yes yes yes
if (!(key in patch)) return then peers.get(id)?.identity ?? {} yes yes yes no
Seed loop over room.peers at attach() yes yes yes yes
#lastSent dedupe with a custom equality yes yes (vec2Equal) yes no
attach/detach/destroy plus "already attached" throw yes yes yes no
Tracked-peer set, drop on peer-left, clear on detach yes yes yes yes
defaultGetLabel reading identity.username yes yes (verbatim copy) no no
new ColorPalette() plus forKey(clientId) yes yes no yes

The payoff is not line count. It is that the traps are only fixed where
someone tripped over them.

PeerFrustumSync now listens to sync and republishes its pose on join.
PixelCursorSync does neither, so a canvas attached before the join snapshot
lands shows no remote cursor until that peer moves, which is the same bug
fixed in three. PeerPresenceGhostSync has the same hole. A shared base means
that class of fix lands once.

Shape

Two pieces rather than one class, because the outbound trigger is what differs
most between consumers: three pulls (sampled in update() from the render
loop), the cursor pushes (canvas event handler), the ghosts push through rAF
coalescing.

// Outbound: owns dedupe, throttle and republish-on-join. Composed, not inherited.
class PresenceReporter<TPayload> {
  constructor(options: {
    room: Room;
    presenceKey: string;
    throttleMs?: number;
    equals?: (a: TPayload, b: TPayload) => boolean;
  });

  // `null` clears the field
  report(payload: TPayload | null): void;
  // drops the dedupe baseline
  invalidate(): void;
}

// Inbound: owns listeners, seeding, per-peer tracking and teardown.
abstract class PresenceMirror<TPayload> {
  protected readonly room: Room;

  protected abstract decode(value: unknown): TPayload | undefined;
  protected abstract applyPeer(
    clientId: string,
    payload: TPayload,
    identity: PeerMetadata
  ): void;
  protected abstract clearPeer(clientId: string): void;

  // reconcile from room.peers
  protected seed(): void;
  protected clearAll(): void;
  destroy(): void;
}

// The common case: a mirror that also reports, wired together.
abstract class PresenceSync<TPayload, TTarget> extends PresenceMirror<TPayload> {
  attach(target: TTarget): void;
  detach(): void;
}

Each host then supplies only what is genuinely its own:

  • three: decode is decodePeerFrustumPose, applyPeer copies the pose
    into a PeerFrustum, clearPeer removes and disposes it. update() calls
    reporter.report(pose).
  • pixel-draw cursor: applyPeer writes canvas.peerPresence.cursors, and
    the local handler calls report() directly.
  • pixel-draw ghosts: keep the rAF coalescing and PeerGhostLeaser on top.
    The base handles neither, and should not.

Where it lives, and why

@jolly-pixel/network, next to SyncAdapter, which is the counterpart on the
other axis:

SyncAdapter PresenceSync
Wire room.send plus message updatePresence plus peer-presence
State authoritative, ordered, replayed ephemeral, last writer wins, unordered
Payload sequence-stamped commands, snapshots a value per peer per key
Lifetime persisted by the server dies with the socket

Network ships half that pair today, and every consumer hand-rolls the other
half.

Reachability is fine: network is already a dependency of pixel-draw and an
optional peer dependency of both ui and three.

Deliberately out of scope

  • PeerGhostLeaser (TTL expiry). Generic, but only one consumer needs it.
    Move it when a second one does.
  • RoomPresenceSource (ui). It looks similar but does a different job: it
    synthesizes the local peer into its map, stamps a host-chosen clientId
    into presence to work around the id gap, and exposes a store with lock
    claims. Forcing it into a mirror fits worse than leaving it alone.
  • Color and label defaults. Tempting, since defaultGetLabel is a
    verbatim copy, but that puts a @jolly-pixel/color dependency and a
    username convention into the wire package. Better as a small shared helper
    elsewhere, or left duplicated.

Migration path

  1. Land PresenceReporter and PresenceMirror in network with unit tests
    against a FakeRoom. That double already exists in three and ui and is
    worth promoting to a network test export.
  2. Port PixelCursorSync first. It is the closest twin, and the port is also
    the fix for its missing sync handling.
  3. Port PeerFrustumSync. The behavior it has today becomes the base's
    behavior and the subclass drops to roughly 80 lines.
  4. Rebase PeerPresenceGhostSync onto the mirror, keeping rAF and the leaser
    as its own layer. Its four subclasses should not need to change.

No public API break if the constructor option shapes stay put. It is a
two-package change touching three shipped classes, so it belongs on its own
branch.

Counter-argument

Three consumers is right at the edge where an abstraction starts paying off.
What tips the balance is the bug symmetry: the same join race has now been
found independently in two of the three, and the third almost certainly has
it too.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions