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
- 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.
- Port
PixelCursorSync first. It is the closest twin, and the port is also
the fix for its missing sync handling.
- Port
PeerFrustumSync. The behavior it has today becomes the base's
behavior and the subclass drops to roughly 80 lines.
- 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.
Proposal:
PresenceSync<T>Status: proposal, nothing implemented yet.
Scope:
@jolly-pixel/network, with@jolly-pixel/three,@jolly-pixel/pixel-draw.rendererand@jolly-pixel/uias 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) andPixelCursorSync(pixel-draw) are close toline-for-line twins: same private field names, same method names, same order.
PeerFrustumSyncPixelCursorSyncPeerPresenceGhostSyncRoomPresenceSourceoffindestroy()if (!(key in patch)) returnthenpeers.get(id)?.identity ?? {}room.peersatattach()#lastSentdedupe with a custom equalityvec2Equal)attach/detach/destroyplus "already attached" throwpeer-left, clear ondetachdefaultGetLabelreadingidentity.usernamenew ColorPalette()plusforKey(clientId)The payoff is not line count. It is that the traps are only fixed where
someone tripped over them.
PeerFrustumSyncnow listens tosyncand republishes its pose on join.PixelCursorSyncdoes neither, so a canvas attached before the join snapshotlands shows no remote cursor until that peer moves, which is the same bug
fixed in three.
PeerPresenceGhostSynchas the same hole. A shared base meansthat 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 renderloop), the cursor pushes (canvas event handler), the ghosts push through rAF
coalescing.
Each host then supplies only what is genuinely its own:
decodeisdecodePeerFrustumPose,applyPeercopies the poseinto a
PeerFrustum,clearPeerremoves and disposes it.update()callsreporter.report(pose).applyPeerwritescanvas.peerPresence.cursors, andthe local handler calls
report()directly.PeerGhostLeaseron top.The base handles neither, and should not.
Where it lives, and why
@jolly-pixel/network, next toSyncAdapter, which is the counterpart on theother axis:
SyncAdapterPresenceSyncroom.sendplusmessageupdatePresencepluspeer-presenceNetwork 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: itsynthesizes the local peer into its map, stamps a host-chosen
clientIdinto 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.
defaultGetLabelis averbatim copy, but that puts a
@jolly-pixel/colordependency and ausername convention into the wire package. Better as a small shared helper
elsewhere, or left duplicated.
Migration path
PresenceReporterandPresenceMirrorin network with unit testsagainst a
FakeRoom. That double already exists in three and ui and isworth promoting to a network test export.
PixelCursorSyncfirst. It is the closest twin, and the port is alsothe fix for its missing
synchandling.PeerFrustumSync. The behavior it has today becomes the base'sbehavior and the subclass drops to roughly 80 lines.
PeerPresenceGhostSynconto the mirror, keeping rAF and the leaseras 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.