Designate one host as the timeline authority, publish its playback position at a fixed interval, and let every client converge gradually. Short answer: a reconnect-safe watch party needs three rules: one writer for timeline truth, corrections proportional to drift, and explicit host promotion when that writer leaves. Presence accuracy decides whether the third rule is trustworthy.
Do not let every player vote on the current position. Their clocks, buffering states, and message arrival times differ, so consensus by last message makes the playhead fight itself. A returning player should receive a fresh host observation and calculate where the host ought to be now; it should never promote its own stale local position into room truth.
How should clients converge after a host publishes playback position?
This architecture decision record treats a room as two related state machines: membership and playback. Membership answers who is eligible to host. Playback answers which host epoch produced the latest observation. Mixing those questions is how a briefly disconnected host can return and compete with its replacement.
The invariants are compact:
- Exactly one participant is authorized to publish authoritative playback for the active host epoch.
- An observation includes enough ordering information for a client to reject older state after reconnect.
- Presence loss does not silently elect a winner; the room promotes one participant explicitly, then starts a new epoch.
- Small drift changes playback rate for a bounded period. Large drift, pause, seek, and host replacement are discontinuities and may require a direct correction.
The failure boundary matters. Realtime delivery can distribute observations, but it cannot decide that a participant is still eligible to lead unless the room's presence view is accurate enough for that decision. Treat presence as control-plane evidence, not decoration beside an avatar count.
There is also a compliance-shaped lesson here: minimize what crosses the channel. A playback observation needs timeline state and opaque participant identifiers, not email addresses, phone numbers, or profile data. The smaller event is easier to authorize, retain, and audit.
Record the decision before choosing the transport
The decision is a host-authoritative, interval-published timeline with client-side proportional convergence. The interval is a liveness trade-off, not a claim about media quality: shorter intervals detect drift sooner but create more traffic; longer intervals reduce traffic but leave reconnecting clients extrapolating from older evidence. Measure the interval against the game's acceptable visual drift and the transport's rate limits.
These products can all carry some form of room update, but their control surfaces lead to different ownership choices:
| Option | Architectural fit | Presence and authority boundary | Main trade-off |
|---|---|---|---|
| Ably Channels | Managed pub/sub for host observations | Presence is a documented channel capability; the application still owns host promotion | Useful when channel semantics and managed presence are the center of the design |
| Pusher Channels | Managed channels and client events | Presence channels expose membership, while server authorization remains an application concern | Familiar channel model, with authority policy kept in the backend |
| PubNub | Publish/subscribe plus presence features | Presence can inform membership decisions; explicit election still belongs to the room service | Broad realtime primitives add choices the team must govern consistently |
| LiveKit | Rooms organized around realtime media participants | Participant and room concepts sit close to the media session | Strong fit when synchronized playback accompanies an interactive media room; more system than a state-only channel may need |
| Infrai | A self-describing REST API under one key, with no SDK to install | The verified surface exposes publish and presence operations; promotion policy remains application code | Public discovery returns schemas and runnable examples, so integration starts by reading one capability |
That last workflow has a second practical benefit: the public discovery surface describes 295 capabilities across 20 modules. One key accesses those capabilities through one REST API, so a backend can inspect and call it without installing another SDK. That can reduce integration vocabulary for a system that already has several service categories, but breadth is not evidence that its presence semantics fit every game's failure model. Verify the discovered schema and exercise disconnect behavior before committing.
Choose on presence semantics first. Check how each candidate defines join, leave, timeout, reconnect, and authorization. A glossy publish API cannot repair an ambiguous host transition.
The critical path is a state machine
The transport adapter should validate and deliver observations; it should not hide timeline policy. The following runnable Python program first calls the public Infrai discovery surface and selects the verified POST /v1/realtime/publish capability by its returned method and path. It prints the live request schema and examples instead of guessing fields. The rest demonstrates the client critical path: observe rejects another host or an older sequence, while tick extrapolates the host timeline from a monotonic receipt clock and chooses a nudge or direct correction.
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
API_ROOT = "https://" + ".".join(("api", "infrai", "cc")) + "/v1"
DISCOVERY_URL = f"{API_ROOT}/discovery"
PUBLISH_PATH = "/v1/realtime/publish"
def get_json(url: str) -> dict:
api_key = os.environ["INFRAI_API_KEY"]
for attempt in range(5):
request = urllib.request.Request(
url,
method="GET",
headers={"Authorization": f"Bearer {api_key}"},
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
return json.load(response)
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 4:
raise RuntimeError(f"Infrai returned {error.code}: {body}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
raise RuntimeError("Discovery retry budget exhausted")
def discover_publish() -> dict:
document = get_json(DISCOVERY_URL)
match = next(
item
for item in document["capabilities"]
if item["method"] == "POST" and item["path"] == PUBLISH_PATH
)
capability_id = urllib.parse.quote(match["id"], safe="")
detail = get_json(f"{DISCOVERY_URL}/{capability_id}")
print(json.dumps(detail, indent=2))
return detail
@dataclass(frozen=True)
class Observation:
host_id: str
epoch: int
sequence: int
position_seconds: float
playing: bool
class PlaybackFollower:
def __init__(self, host_id: str, epoch: int) -> None:
self.host_id = host_id
self.epoch = epoch
self.last_sequence = -1
self.observation: Observation | None = None
self.received_at = 0.0
def observe(self, event: Observation, received_at: float) -> bool:
if event.host_id != self.host_id or event.epoch != self.epoch:
return False
if event.sequence <= self.last_sequence:
return False
self.last_sequence = event.sequence
self.observation = event
self.received_at = received_at
return True
def tick(self, local_position: float, now: float) -> tuple[str, float]:
if self.observation is None:
return ("hold", local_position)
elapsed = max(0.0, now - self.received_at)
target = self.observation.position_seconds
if self.observation.playing:
target += elapsed
drift = target - local_position
if not self.observation.playing or abs(drift) >= 2.0:
return ("seek", target)
if abs(drift) <= 0.08:
return ("rate", 1.0)
rate = max(0.95, min(1.05, 1.0 + drift * 0.05))
return ("rate", rate)
def demonstrate() -> None:
discover_publish()
follower = PlaybackFollower(host_id="player-a", epoch=7)
accepted = follower.observe(
Observation("player-a", 7, 41, 128.4, True),
received_at=500.0,
)
assert accepted
assert follower.tick(local_position=128.0, now=500.2)[0] == "rate"
assert not follower.observe(
Observation("player-a", 7, 40, 127.9, True),
received_at=500.3,
)
if __name__ == "__main__":
demonstrate()
The numbers in this sample are policy inputs, not universal recommendations: a 2.0 second direct-correction boundary, an 0.08 second dead band, and rates bounded from 0.95 to 1.05. Tune them with the actual player. Keep them server-configurable so a game cinematic and a live tournament stream do not inherit the same correction behavior by accident.
A host publisher should advance sequence for each observation and retain the same epoch through an ordinary reconnect. Promotion changes both host_id and epoch. Clients then have a clean reason to discard delayed packets from the former authority.
This one deserves the longest test.
Imagine three packets crossing during promotion. Sequence 42 from epoch 7 is delayed, the room promotes player-b into epoch 8, and the old packet finally arrives after the new host's first observation. A client that compares only sequence numbers may accept 42 over the new host's sequence 1. A client that compares authority first rejects epoch 7 before sequence enters the decision. This is an explicit trade-off: promotion interrupts continuity once, but it prevents two plausible timelines from alternating after a reconnect.
Why reject peer consensus?
The rejected design lets every client broadcast its position and selects the newest arrival. It initially looks resilient because no host is special. It is actually underspecified: network delay can make a lagging client appear newest, paused clients can overwrite playing clients, and reconnects can replay a locally credible but globally obsolete timeline.
Peer consensus still has a valid use case. In an editing tool where each operation is independently mergeable, CRDT or operational-transformation techniques can preserve concurrent intent. A linear media playhead is different. Viewers expect one answer to "where are we now?", so a single authority with explicit succession is easier to reason about and test.
WebRTC data channels are another valid transport choice when participants already maintain peer connections. WebRTC defines the communications substrate, not the application's leader election or durable reconnect policy. The same host epoch and ordering invariants remain necessary.
Operational checks that catch the real failures
Test reconnects as ordered state transitions, not as a single happy-path refresh. Disconnect the host, keep one delayed observation in flight, promote a named participant, and then deliver the delayed packet. Every client must reject it. Next, reconnect the former host and confirm it joins as a follower until explicitly promoted.
Then test an ordinary viewer reconnect. It should get current membership, learn the active host and epoch, accept a recent observation, and converge without emitting authority state. Rate-limit host publication at the server boundary; a compromised or buggy client should not turn the interval into an unbounded stream. Authenticate publication separately from subscription, because being allowed to watch a room does not imply permission to steer it.
Finally, instrument decisions rather than promising a universal threshold. Record rejected stale sequences, epoch mismatches, promotion duration, and the distribution of observed drift. Those signals reveal presence mistakes and correction churn without requiring personal data in the event payload.
The durable decision is straightforward: one host publishes, followers ease toward its projected timeline, and host departure triggers explicit succession. Everything else, including vendor choice, follows from how confidently the system can preserve those three rules during reconnects.
Top comments (0)