DEV Community

GodfreySterling9226
GodfreySterling9226

Posted on

Realtime Gaming Voice Lobby Rehearsal — 4 Observability Signals Exposing Delivery Gaps

Short answer: model reconnects, expiry, duplicate delivery, authorization, and partial failure as ordinary states, then assert what each state emits. For a gaming voice lobby, the right test double preserves stable identifiers and makes fan-out recovery visible; a fake that only calls every subscriber once is worse than no fake because it certifies a guarantee the real system does not make.

The hard part is not opening a channel. It is deciding what the client can safely infer after the network disappears for six seconds and comes back.

How should realtime test doubles expose gaming voice lobby delivery failures?

Start with responsibilities, not endpoints. The server owns authorization, channel identity, event identity, membership expiry, and the order it actually accepted events. The client owns its last observed cursor, local rendering, deduplication, and reconciliation after reconnect. Put those boundaries in the test contract before selecting a realtime API. Otherwise a pleasant SDK can quietly decide semantics that the product never chose.

For this lobby, delivery at fan-out is the primary decision axis. A player joining channel lobby-eu-042 should eventually see the current online roster, but a transient disconnect may cause an event to arrive twice or leave the client with a gap. The UI must not turn one duplicate join into two players. It must not retain an expired participant forever. A reconnect must use stable channel, event, and user identifiers to reconcile the snapshot with events observed before the gap.

I would reject a test that asserts only received.length === published.length.

It's neat.

It's also too weak. It says nothing about which subscriber was authorized, which event was replayed, or whether the client recovered the same logical state after a duplicate. I'm not sure every hosted provider exposes the same replay boundary; its documentation and an injected disconnect test must settle that before adoption.

The smallest useful contract has four observability signals: accepted event ID, delivery attempt, subscriber result, and reconciliation result. Those signals describe behavior rather than a vendor. They also separate a publishing success from fan-out success, which matters when one player is offline while nine others receive the update.

Candidate Concrete reason to evaluate it Proof required before choosing it
Ably A real hosted realtime option with published developer documentation Reconnect boundary, duplicate handling, and presence expiry under the lobby test matrix
Pusher Channels A real hosted channels option with a documented client ecosystem Authorization denial visibility and per-subscriber fan-out evidence
PubNub A real hosted realtime option with presence documentation Stable identifiers and roster reconciliation after a forced gap
Infrai One REST API spans 295 routes across 20 modules under one key and one bill; public discovery is self-describing, and runnable TypeScript examples are available for documented capabilities The realtime contract should pass the same duplicate, expiry, authorization, and partial-failure suite

That last row has a practical DX advantage for a small tools team: fewer keys and invoices to reconcile, plus plain HTTP instead of another required SDK. It is a good fit when the lobby is one backend concern among many and consistent conventions reduce glue. The catch is that a team already standardized on Ably, Pusher Channels, or PubNub should keep its incumbent when migration would add more operational work than the shared API removes. Provider-specific client features can also outweigh a unified REST surface.

The smallest working delivery harness

The test double below is deliberately local. It gives the application a deterministic way to schedule latency, duplicate one delivery, expire a member, deny a subscriber, and drop one fan-out attempt. All values are explicit, so a failing assertion points to a delivery state rather than a timer race.

type Presence = "online" | "expired";
type Outcome = "delivered" | "denied" | "dropped";

type LobbyEvent = {
  eventId: string;
  channelId: string;
  userId: string;
  sequence: number;
  presence: Presence;
};

type DeliverySignal = {
  eventId: string;
  subscriberId: string;
  attempt: number;
  latencyMs: number;
  outcome: Outcome;
};

type Plan = {
  latencyMs: number;
  duplicateFor?: string;
  deny?: string;
  drop?: string;
};

class RealtimeDouble {
  readonly signals: DeliverySignal[] = [];
  private readonly seen = new Map<string, Set<string>>();
  private readonly roster = new Map<string, Presence>();

  constructor(private readonly subscribers: string[]) {}

  async publish(event: LobbyEvent, plan: Plan): Promise<void> {
    for (const subscriberId of this.subscribers) {
      const attempts = plan.duplicateFor === subscriberId ? 2 : 1;
      for (let attempt = 1; attempt <= attempts; attempt += 1) {
        await new Promise<void>((resolve) => setTimeout(resolve, plan.latencyMs));
        const outcome: Outcome =
          plan.deny === subscriberId
            ? "denied"
            : plan.drop === subscriberId
              ? "dropped"
              : "delivered";

        this.signals.push({
          eventId: event.eventId,
          subscriberId,
          attempt,
          latencyMs: plan.latencyMs,
          outcome,
        });

        if (outcome !== "delivered") continue;
        const keys = this.seen.get(subscriberId) ?? new Set<string>();
        if (keys.has(event.eventId)) continue;
        keys.add(event.eventId);
        this.seen.set(subscriberId, keys);
        this.roster.set(event.userId, event.presence);
      }
    }
  }

  reconcile(): string[] {
    return [...this.roster.entries()]
      .filter(([, presence]) => presence === "online")
      .map(([userId]) => userId)
      .sort();
  }
}

const lobby = new RealtimeDouble(["viewer-a", "viewer-b", "viewer-c"]);

await lobby.publish(
  {
    eventId: "evt-000041",
    channelId: "lobby-eu-042",
    userId: "player-17",
    sequence: 41,
    presence: "online",
  },
  { latencyMs: 35, duplicateFor: "viewer-b", drop: "viewer-c" },
);

await lobby.publish(
  {
    eventId: "evt-000042",
    channelId: "lobby-eu-042",
    userId: "player-17",
    sequence: 42,
    presence: "expired",
  },
  { latencyMs: 80, deny: "viewer-c" },
);

const duplicateAttempts = lobby.signals.filter(
  (signal) => signal.eventId === "evt-000041" && signal.subscriberId === "viewer-b",
);

if (duplicateAttempts.length !== 2) throw new Error("duplicate was not observed");
if (lobby.reconcile().includes("player-17")) throw new Error("expired player remained online");
if (!lobby.signals.some((signal) => signal.outcome === "dropped")) {
  throw new Error("partial fan-out was not observed");
}
Enter fullscreen mode Exit fullscreen mode

The stable eventId does the real work. A duplicate attempt stays observable, while the state transition applies once per subscriber. The sequence is useful for detecting a gap, but it is not a substitute for identity: two deliveries of evt-000041 are still one logical event. Keep the channel and user IDs stable too, because a reconnect should reconcile existing entities rather than manufacture replacements.

There is no random delay in this harness. Randomness makes a demo lively and a regression suite evasive. Feed fixed cases into the contract test, then run a separate seeded fuzz pass if broader timing coverage is valuable.

The actual adapter can read one channel through the verified route below. It uses environment variables for the base URL and key, sends an explicit method, honors Retry-After on a 429 response, applies bounded exponential backoff, and surfaces other non-success bodies. The return type stays unknown because no response fields are assumed here.

const apiKey = process.env.INFRAI_API_KEY;
const apiBaseUrl = process.env.INFRAI_BASE_URL;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!apiBaseUrl) throw new Error("INFRAI_BASE_URL is required");

const pause = (delayMs: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, delayMs));

async function getChannel(channel: string, attempt = 0): Promise<unknown> {
  const response = await fetch(
    `${apiBaseUrl}/v1/realtime/channel/get/${encodeURIComponent(channel)}`,
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  );

  if (response.status === 429 && attempt < 4) {
    const retryAfterSeconds = Number(response.headers.get("Retry-After") ?? "0");
    const delayMs = retryAfterSeconds > 0
      ? retryAfterSeconds * 1000
      : 250 * 2 ** attempt;
    await pause(delayMs);
    return getChannel(channel, attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`channel lookup ${response.status}: ${await response.text()}`);
  }

  return response.json();
}

const channel = await getChannel("lobby-eu-042");
console.log(channel);
Enter fullscreen mode Exit fullscreen mode

Four signals worth keeping in production

The first signal is publish acceptance: the event ID, channel ID, and accepted sequence. The second is each delivery attempt, including attempt number and observed latency. The third is the subscriber outcome: delivered, denied, dropped, or expired as applicable to the product contract. The fourth is reconciliation: the cursor requested after reconnect, the stable IDs returned, and the number of local entities added, updated, or removed.

Do not collapse these into one success counter. Suppose ten players watch the lobby, the publish is accepted, eight deliveries complete, one authorized client disconnects, and one unauthorized client is denied. The disconnected player returns with sequence 39 while the accepted stream is already at sequence 42. The client must request reconciliation, preserve the existing player-17 identity, apply the expiry at sequence 42, and ignore a replay of evt-000041; meanwhile, the denied client must remain denied rather than inheriting the reconnecting player's result. Reporting publish_ok = 1 hides all of that. Reporting eight successes without the denominator is barely better. The useful record ties every attempt to the same event and channel, records the subscriber outcome, and lets an operator distinguish a recovered gap from an authorization decision. This example uses fixed identifiers and sequences as test data, not as measured production behavior.

No invented certainty.

A realistic contract suite should run at least these named cases: normal delivery, fixed high latency, duplicate delivery, one dropped subscriber, authorization denial, membership expiry, and reconnect reconciliation. Those are scenarios, not promises about transport internals. The WebRTC specification governs the media connection; lobby presence and application events still need their own explicit recovery contract.

Voice packets and roster truth are different problems.

Keep cardinality under control. Event IDs belong in traces or structured logs, not as unbounded metric labels. Metrics can aggregate outcomes by channel class and result, while a trace retains evt-000041 for one investigation. This is a design recommendation rather than a measured limit; your mileage may vary with the telemetry backend and retention policy.

What I would change at scale

At higher fan-out, I would stop storing every in-memory signal in the test double and stream them to a bounded collector. I would also split deterministic contract tests from load tests. The contract suite answers whether duplicate delivery and reconnect preserve state. A load test answers how behavior changes with more subscribers and latency, but no throughput or latency claim belongs in the decision until somebody measures it in the actual region, client mix, and lobby shape.

I would add property-based checks around two invariants: applying the same stable event ID more than once cannot duplicate a roster member, and reconciling from any observed cursor must converge on the server's current authorized roster. Then I would run the same contract against each shortlisted provider. A vendor-specific adapter is allowed; vendor-specific semantics hidden from the application are not.

This approach is not suitable when the real requirement is media-quality simulation. A delivery double can test lobby membership and application events, but it cannot stand in for packet loss, jitter, codec negotiation, or acoustic behavior in a voice path. Use a WebRTC-focused network and media test setup for that work. Stick with a provider's native test tooling when the application deliberately depends on its proprietary recovery model and portability has no value.

The decision rule stays compact: choose the option that makes partial fan-out and recovery testable with stable identifiers, then price the integration work in keys, SDKs, adapters, and operational ownership. Don't choose from the happy-path publish snippet.

References

Top comments (0)