Short answer: I chose capacity planning first because a multiplayer quiz game fails at the reconnect boundary, not when the first websocket connects. Plan for the peak number of active rooms, the burst of joins before a question, and the replay work required after a client returns. A video room with scoped tokens adds another hard limit: media sessions and game sessions must be budgeted separately.
I learned this while designing a property-management flow where a resident joins a live quiz in a video room. The token endpoint could be perfectly healthy while the room was already overloaded with reconnecting clients. A late answer then looked like a game bug, although the real fault was that the event log had no affordable backfill path. My rule became boring and useful: capacity is the number of events we can accept, retain, and replay within the user-visible deadline.
How should capacity planning shape a realtime multiplayer quiz game?
Start with a workload model instead of a server count. Let R be concurrent rooms, P the players per room, and E the events per player per minute. A quiz has a lumpy rate: answer submissions cluster around the timer boundary, while presence and heartbeat traffic are steadier. The planning rate is therefore a burst envelope, not the daily average.
For each room, budget four lanes: signaling, media, game commands, and backfill reads. Signaling negotiates the video session and issues scoped tokens. Media consumes CPU, bandwidth, and often a separate SFU pool. Game commands need ordering and idempotency. Backfill reads protect a returning client from having to trust whatever messages happened to arrive during a flaky minute. In a property-management rollout, I would model the five-minute resident check-in separately from the evening tournament: the former stresses token issuance and camera joins, while the latter creates a synchronized answer burst. That distinction changes queue sizes, alert thresholds, and even which component gets a second replica. Averaging both schedules into one rate would hide the exact spike that causes a missed question.
I keep a small worksheet for every launch.
| Quantity | Question to answer |
|---|---|
| Peak rooms | How many rooms overlap during the busiest minute? |
| Burst factor | How many answers arrive in one timer tick? |
| Replay window | How many seconds of events can a reconnecting client request? |
| Recovery budget | How quickly must state become usable after reconnect? |
| Media headroom | What bandwidth remains when cameras turn on together? |
The exact multiplier is workload-specific; your mileage may vary. I am not sure a single “connections per node” number can survive a game format change, so I re-run the model whenever timers, room size, or camera defaults change.
The reconnect boundary is the real capacity test
A client that reconnects creates two jobs. It needs a fresh transport, and it needs a consistent state snapshot. Treating those as one operation creates a thundering herd: thousands of clients ask for tokens, subscriptions, and every missed event at once.
Use a monotonic sequence per room. The client reports its last applied sequence; the service either sends events after that point or returns a snapshot plus the sequence that snapshot represents. The snapshot path should be bounded. If the gap is too large, read a compact state record and resume from its sequence instead of replaying an unbounded list.
Here is the decision path in Go. It deliberately keeps token issuance, event acceptance, and replay as separate interfaces so each can be scaled and observed independently.
package room
import (
"context"
)
type Event struct {
RoomID string
Seq uint64
Kind string
Body []byte
}
type Store interface {
Current(ctx context.Context, roomID string) (uint64, error)
Replay(ctx context.Context, roomID string, after uint64, limit int) ([]Event, error)
Snapshot(ctx context.Context, roomID string) (uint64, []byte, error)
}
func Backfill(ctx context.Context, store Store, roomID string, last uint64, max int) ([]Event, uint64, []byte, error) {
current, err := store.Current(ctx, roomID)
if err != nil {
return nil, 0, nil, err
}
if last >= current {
return nil, current, nil, nil
}
events, err := store.Replay(ctx, roomID, last, max)
if err != nil {
return nil, 0, nil, err
}
if len(events) < max && last+uint64(len(events)) >= current {
return events, current, nil, nil
}
seq, snapshot, err := store.Snapshot(ctx, roomID)
if err != nil {
return nil, 0, nil, err
}
return nil, seq, snapshot, nil
}
The short bit matters. A reconnect request that is already current should cost one bounded read, not a scan. For answer commands, attach a client command ID and record it with the room sequence; retries then become harmless duplicates instead of extra points.
How do video-room tokens fit a multiplayer game capacity model?
A scoped token should authorize one room, one participant, and only the media actions that participant needs. It should expire, and the room service should be able to reject a token that names a different room. Those checks are security properties, but they also make capacity measurable: token issuance rate is distinct from media bitrate and from answer-event rate.
WebRTC gives the browser APIs and negotiation model; it does not choose your queue, replay store, or autoscaling policy. The W3C recommendation is the right baseline for browser behavior, while your service still has to define timeouts, sequence semantics, and admission control.
I set admission thresholds before launch: maximum players per room, maximum pending backfill requests, and a queue deadline for token issuance. When a threshold is reached, fail a new join clearly and preserve existing rooms. Silent acceptance is worse because it converts a visible capacity decision into missing answers.
What should we measure before changing the architecture?
Measure per-room event rate, p95 and p99 backfill latency, reconnect success within the recovery budget, duplicate-command rate, token issuance latency, and media egress. Tag metrics with room size and game phase; an average across those dimensions hides the timer spike that pages you.
Run a load test that follows the clock: join, token refresh, question open, answer burst, result broadcast, disconnect, and reconnect. Inject packet loss and delayed acknowledgements. Verify that the recovered client reaches the same sequence as a continuously connected client, and verify that a retried answer changes the score once.
The trade-off is explicit. A longer replay window improves recovery but consumes storage and read capacity. A larger snapshot reduces replay work but increases write and serialization cost. Keep the simple event path when rooms are small and reconnects are rare; choose snapshots and stricter admission when a missed question is more damaging than a rejected join.
It is a capacity problem.
This approach is not suitable when the product requires unconstrained room sizes or long-lived media recording; use a dedicated media capacity model and a separate recording pipeline then.
Top comments (0)