Realtime (SSE)
Stumper multiplayer uses Mercure for real-time updates. Mercure is a thin SSE-based pub/sub protocol bundled with Symfony. From the client's perspective it's just an EventSource against a single URL.
Topics
Each lobby publishes on its own private topic:
https://stumper.gg/lobby/{code}
The topic is private — to subscribe, you need a JWT minted by the API that has a mercure.subscribe claim listing the topic. The API only mints a token if you're a member of the lobby.
What gets published
The full lobby state is published on every change: a player joining, the host advancing, an answer being submitted, the timer expiring, the host going stale. The payload is identical to GET /v1/games/stumper/lobbies/{code}/status:
{
"id": 1,
"code": "ABC123",
"status": "in_progress",
"current_index": 3,
"current_question_started_at_ms": 1736953200000,
"server_now_ms": 1736953215123,
"current_question": {
"id": 4242,
"category": "science",
"difficulty": "medium",
"question": "What is the chemical symbol for gold?",
"choices": [ "Au", "Ag", "Pb", "Fe" ],
"answer": null
},
"players": [ /* ... */ ],
"answered_player_ids": [ 12, 18 ],
"host_disconnect": null
}
When the question ends (all answered, timer expired, or host clicked Next), the same payload is republished with the answer field set and an explanation. That's how every subscriber sees the reveal at the same time.
The full schema is documented in the API Reference under the stumper.lobby.update webhook.
Subscribing — the full flow
// 1. Create or join a lobby
const { lobby } = await katforge.games.stumper.createLobby ({
hostName: 'anders',
numQuestions: 10,
visibility: 'public'
});
// 2. Mint the Mercure subscriber JWT
// The server sets `mercureAuthorization` as an HttpOnly cookie scoped
// to /.well-known/mercure, so the browser sends it automatically.
await katforge.games.stumper.mintLobbyToken (lobby.code);
// 3. Open the EventSource
const url = new URL ('https://api.katforge.com/.well-known/mercure');
url.searchParams.append ('topic', `https://stumper.gg/lobby/${lobby.code}`);
const stream = new EventSource (url, { withCredentials: true });
stream.onmessage = (event) => {
const lobby = JSON.parse (event.data);
// Drive your UI
updatePlayers (lobby.players);
if (lobby.current_question) {
showQuestion (lobby.current_question);
}
if (lobby.host_disconnect) {
showDisconnectBanner (lobby.host_disconnect);
}
};
stream.onerror = () => {
// Mercure auto-reconnects on its own. If the JWT expired (rare; 24h
// lifetime), re-mint it before reconnecting.
if (stream.readyState === EventSource.CLOSED) {
katforge.games.stumper.mintLobbyToken (lobby.code);
}
};
Sending heartbeats
Players post a heartbeat every ~30 seconds while connected:
const heartbeat = setInterval (() => {
katforge.games.stumper.heartbeat (lobby.code, playerId);
}, 30_000);
If the host stops heartbeating for more than the stale threshold, the lobby publishes a host_disconnect payload to all subscribers with a countdown the UI should display.
Source-of-truth pattern
The DB is always the source of truth. Mercure deliveries are best-effort:
- If a publish fails (network blip), the next state change publishes anyway.
- On reconnect, always call
GET /v1/games/stumper/lobbies/{code}/statusonce to re-sync before processing further deliveries. - Treat the SSE stream as a notification system, not a transactional log. If you missed an event, the next event will catch you up.
Why not WebSockets?
Mercure is one-way (server → client) and runs over plain HTTP/2, so it works through corporate proxies, doesn't need a separate handshake, and gets free HTTP caching. The client side is just EventSource — three lines of code, native to every browser. We don't need bidirectional messaging because every client → server action is already a normal REST call.