DEV Community

ColeMitchell4991
ColeMitchell4991

Posted on

Multiplayer Quiz Presence: Safe Realtime Channel Deletion in Python

Short answer: treat channel deletion as a protocol state transition, not as a socket operation. Mark the quiz session closing, stop admitting messages, drain or reject late events, then close the peer connection only after the authoritative state is durable. A reconnecting player should receive a fresh session decision, never guess whether the old channel still exists.

In a marketplace-style quiz, presence accuracy is the decision axis. A player can be shown as online while their answer is already irrelevant, or shown offline while a score update is still in flight. Both cases damage trust. The implementation below keeps the source of truth in a small state machine and makes every teardown request idempotent.

How should a multiplayer quiz game handle realtime channel deletion?

The data flow is deliberately boring. A Python worker owns the session record; a signaling layer exchanges WebRTC offers, answers, and ICE candidates; browsers carry quiz events over a data channel. The worker decides when a round ends or a host removes a player. It records that decision before asking the transport to close. WebRTC defines the peer connection and data-channel states, but your application still owns authorization, ordering, and replay policy.

Here is a minimal controller using an in-memory store so the lifecycle is visible. In production, replace the store with a transactional database or a consensus-backed service. The transport adapter can wrap any WebRTC implementation.

from dataclasses import dataclass, field
from enum import Enum
from threading import Lock
from typing import Dict, Optional


class ChannelState(str, Enum):
    OPEN = "open"
    CLOSING = "closing"
    CLOSED = "closed"


@dataclass
class QuizChannel:
    channel_id: str
    state: ChannelState = ChannelState.OPEN
    close_reason: Optional[str] = None
    seen_events: set[str] = field(default_factory=set)


class ChannelRegistry:
    def __init__(self) -> None:
        self._channels: Dict[str, QuizChannel] = {}
        self._lock = Lock()

    def create(self, channel_id: str) -> QuizChannel:
        with self._lock:
            channel = self._channels.setdefault(channel_id, QuizChannel(channel_id))
            if channel.state is ChannelState.CLOSED:
                raise ValueError("channel_id belongs to a closed quiz session")
            return channel

    def accept_event(self, channel_id: str, event_id: str) -> bool:
        with self._lock:
            channel = self._channels.get(channel_id)
            if channel is None or channel.state is not ChannelState.OPEN:
                return False
            if event_id in channel.seen_events:
                return False
            channel.seen_events.add(event_id)
            return True

    def delete(self, channel_id: str, reason: str) -> bool:
        with self._lock:
            channel = self._channels.get(channel_id)
            if channel is None or channel.state is ChannelState.CLOSED:
                return False  # An already-completed delete is a safe retry.
            channel.state = ChannelState.CLOSING
            channel.close_reason = reason
            return True

    def mark_closed(self, channel_id: str) -> None:
        with self._lock:
            channel = self._channels.get(channel_id)
            if channel is not None:
                channel.state = ChannelState.CLOSED

Enter fullscreen mode Exit fullscreen mode

The important ordering is OPEN -> CLOSING -> CLOSED. delete does not pretend that the network has already stopped; it changes the admission rule immediately. A message arriving between those two moments is rejected by accept_event, and a duplicate delete is harmless. The event ID is also the boundary between retry and replay: an answer processor can acknowledge an ID twice while applying its score once.

Close twice. The second call should be boring.

Keep transport shutdown outside the lock. After the state transition commits, notify the signaling service, call the peer connection's close() method, and publish a final presence update. If a process dies after the database commit but before close(), a sweeper can finish the transport work. If it dies before the commit, a retry sees OPEN and performs the same transition. That is the useful failure boundary.

Failure paths that make presence lie

The first failure is a half-delete: one browser receives a close event while another still has a sendable data channel. The server must reject events by state, not by what a client reports. Client-side readyState is helpful telemetry, not authorization.

The second is a reconnect racing teardown. Give each session a monotonically increasing generation, or a random session incarnation ID. A reconnect presents its incarnation; an old close callback cannot mark the new incarnation offline. This prevents a delayed callback from erasing fresh presence.

The third is a lost final notification. Presence should be derived from durable session state on read, with transport callbacks treated as hints. A periodic reconciliation job can compare active connections with OPEN records and close abandoned ones. Pick a timeout from observed network behavior and document it; I'm not sure one universal value exists across mobile networks.

The fourth is a split brain between scoring and teardown. Store the round result and the close transition in one transaction when the rules require that atomicity. Otherwise, explicitly define which outcome wins and expose it in the audit trail. “The channel closed” is not an explanation for a missing score.

A test matrix before shipping

An eval-driven harness should exercise the state machine without a browser first, then add real peer connections for transport behavior. The matrix below is small enough to run on every change.

Scenario Expected invariant Useful assertion
Delete retried three times One durable transition Delete side effect has one audit record
Event races with delete No post-close mutation Score version is unchanged after CLOSING
Old reconnect arrives late New incarnation stays present Generation on the record is newest
Worker crashes after commit Session is not admitted Sweeper eventually closes transport
Duplicate answer event Exactly-once scoring Same event_id yields one score delta

For a Python test, inject a fake transport and control the scheduler. Force the sequence delete commit -> process stop -> reconnect -> sweep; randomize message arrival around each edge. For example, send answer evt-1042 just after the host's delete request, kill the worker before the browser sees close(), then let a reconnect present incarnation 8 while the stale callback still claims incarnation 7. The expected trace is explicit: evt-1042 is rejected, the old callback is ignored, and the sweeper moves the committed record from CLOSING to CLOSED. Record transition latency, rejected-event counts, and the age of CLOSING sessions. Those metrics tell you whether presence is stale, while logs with channel ID, incarnation, and event ID let you reconstruct a disputed round without storing answer content.

Choosing the transport boundary

WebRTC is a good fit when browsers need direct, low-latency data channels and your application already operates a signaling path. Its specification covers RTCPeerConnection, ICE negotiation, and data-channel lifecycle; it does not define your quiz authorization or durable event log. That division is healthy: keep game rules in Python and keep media or network mechanics behind an adapter.

A server-relayed WebSocket path can be simpler for authoritative fan-out, especially when every answer must pass through one region. A WebRTC data channel can reduce relay work but introduces signaling, NAT traversal, and another set of connection states to observe. The right choice depends on where you need ordering and auditability. Do not select a transport from a latency chart alone.

The catch is that this pattern is not suitable when you need a globally ordered, permanently replayable stream from the transport itself; use a durable log and treat the realtime channel as a view. Stick with a simpler server push path when your team cannot operate ICE and signaling diagnostics. That is an operational boundary, not a failure of the protocol.

Before launch, write the deletion contract in one page: who may request it, which events are accepted in each state, how long CLOSING may last, and what a reconnect receives. Then run the matrix under packet delay, duplicate delivery, process restarts, and clock skew. Keep prompt payloads and telemetry lean if an AI agent is generating quiz content, but never remove event IDs or transition records to save tokens. Those fields pay for themselves during the first disputed score.

References

Top comments (0)