Realtime
Stumper.gg multiplayer uses Mercure for real-time updates. Mercure is a pub/sub protocol layered on Server-Sent Events: clients subscribe to named topics, servers publish messages to those topics, and the hub fans them out. From the client's perspective it's just an EventSource.
Subscribe to a lobby
// 1. Mint the Mercure JWT (sets HttpOnly cookie)
await katforge.games.stumper.mintLobbyToken (lobbyCode);
// 2. Open the stream
const url = new URL ('https://api.katforge.com/.well-known/mercure');
url.searchParams.append ('topic', `https://stumper.gg/lobby/${lobbyCode}`);
const stream = new EventSource (url, { withCredentials: true });
stream.onmessage = (event) => {
const lobby = JSON.parse (event.data);
updateUI (lobby);
};
What gets published
The full lobby state on every change (player joined, answer submitted, question advanced, host disconnected). Same shape as GET /v1/games/stumper/lobbies/{code}/status. Clients treat each payload as an idempotent overwrite — no per-message-type handlers, no event diffing, just replace the local state with whatever the server most recently sent.
Publishing from the server
The API uses Symfony's Mercure bundle. Services inject HubInterface, build a snapshot, and publish a single Update per state transition. That's it. No broker queue, no per-subscriber fan-out to manage.
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Psr\Log\LoggerInterface;
class LobbyManager
{
public function __construct (
private HubInterface $hub,
private LoggerInterface $logger
) {}
// Publish the full lobby snapshot — same shape as GET /lobbies/{code}/status.
// Callers pass the already-loaded entities so we don't re-query on every
// transition. Failures are logged but never propagate: the DB is the source
// of truth and clients re-sync on reconnect.
private function publishLobby (LobbyEntity $lobby, array $players): void
{
try {
$payload = $this->buildStatus ($lobby, $players);
$this->hub->publish (new Update (
"https://stumper.gg/lobby/{$lobby->getCode ()}",
json_encode ($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
private: true
));
} catch (\Throwable $e) {
$this->logger->warning ('mercure publish failed', [
'code' => $lobby->getCode (),
'error' => $e->getMessage ()
]);
}
}
}
Two things worth calling out:
private: true: subscribers must present a JWT whosemercure.subscribeclaim lists the topic. The API mints that JWT only after verifying the caller is a lobby member. Public topics (like the public lobby list) skip the flag.- Throw-safe: a dead Mercure hub can't take down a REST write. The transaction commits, the warning logs, and the next successful publish (or the client's reconnect re-sync) brings everyone back in line.
Public topics
The lobby browser subscribes to an unauthenticated topic. Same API, no private flag:
$this->hub->publish (new Update (
'https://stumper.gg/lobbies',
json_encode ([ 'lobbies' => $this->list () ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
));
When to publish
Publish after the DB write commits, not before. The write is authoritative; the event is a notification. Ordering:
- Mutate state (Doctrine flush)
- Build snapshot from the committed state
hub->publish (...)
If step 3 fails, the state is still correct; the next event will overwrite. If you publish before committing and the write rolls back, subscribers get a phantom update.
Source-of-truth pattern
The DB is authoritative. Mercure deliveries are best-effort. On reconnect, always call lobbyStatus(code) once to re-sync before processing further deliveries.
Why not WebSockets
One-way is sufficient (every client → server action is a REST call). SSE rides on plain HTTP/2, crosses every proxy, and EventSource is native to every browser. No library needed.