Real-time multiplayer with Laravel Reverb
Running two live multiplayer games on Laravel Reverb: channel design for hidden information, the ShouldBroadcastNow trap, and the systemd setup.
- What Reverb gives you
- Design the channels before you write an event
- Channel authorization is where your game rules live
- Hidden information: do not broadcast what one player must not see
- The ShouldBroadcastNow trap
- Give every event a version
- Running it in production
- The client, and letting it be absent
- What we would skip
We run two multiplayer games on Laravel Reverb in production: Connect Fo, a ranked Connect Four with chat and a turn clock, and Foduel, a trading card game with hidden hands. Both sit on one Reverb process on one small VM.
This is what we learned putting it there, in the order it bit us.
What Reverb gives you
Reverb is a WebSocket server that speaks the Pusher protocol, written in PHP and
run with php artisan reverb:start. Because it speaks Pusher, the existing
client library (Laravel Echo with pusher-js) works unchanged, and so does
Laravel's whole broadcasting layer: ShouldBroadcast, channel authorization,
presence channels.
What that means practically is that you are not learning a new realtime framework. You are learning where to put a process and how to design your channels.
Design the channels before you write an event
Three channel types, three jobs. Getting this split right is most of the work.
Public channels. We use none. If a game has any authorization at all, a public channel is a leak waiting to be found.
Private per-user channels. One per player, connect-fo.user.{id} and
foduel.user.{id}. This is where "your match is ready" lands while a player is
sitting in a queue, and where anything only that player may see goes.
Presence channels per match. connect-fo.match.{id}, joined by both
players. Presence gives you two things: a shared broadcast target, and a
membership list that changes when someone's socket drops. That membership event
is the fastest disconnect detection you will get, far quicker than waiting for a
turn clock to expire.
Channel authorization is where your game rules live
The callback in routes/channels.php is not a formality. It is the only thing
standing between a curious player and someone else's match.
Broadcast::channel('connect-fo.match.{matchId}', function ($user, $matchId) {
$match = ConnectFoMatch::find($matchId);
if (! $match || ! $match->isRanked()) {
return false;
}
if (! in_array($match->status, [STATUS_PENDING, STATUS_ACTIVE], true)) {
return false;
}
if (! $match->isParticipant((int) $user->id)) {
return false;
}
return [
'id' => $user->id,
'name' => $user->name,
'avatar' => $user->avatarUrl(),
'color' => $user->connectFoColor(),
'seat' => $match->seatForUser((int) $user->id),
];
});
Three checks, then a payload. The array a presence callback returns is what every other member of that channel receives about this user, so it doubles as your "who am I playing" endpoint. We put the opponent's display name, avatar, chosen chip colour, and seat in there, which removes a round trip at match start.
Note what the checks reject: a match that has finished. When a game ends, the channel stops authorizing, and stale clients cannot rejoin to listen.
Hidden information: do not broadcast what one player must not see
This is the design decision we would most want to hand to somebody starting out.
Connect Four has no hidden state. The board is the board, so both players get the same event on the shared presence channel:
class MoveMade implements ShouldBroadcastNow
{
public function broadcastOn(): array
{
return [new PresenceChannel("connect-fo.match.{$this->matchId}")];
}
public function broadcastAs(): string
{
return 'move.made';
}
}
A card game is the opposite. Each player has a hand the other must never see. So Foduel never broadcasts a shared board at all. After every action the server builds one redacted view per seat and sends each to that player's own private channel:
class ViewUpdated implements ShouldBroadcastNow
{
public function broadcastOn(): array
{
return [new PrivateChannel("foduel.user.{$this->userId}")];
}
}
It costs one extra event per action. It also means a hidden card is never present in any payload that reaches the wrong browser, so there is nothing to find in devtools. Redaction that happens on the client is not redaction.
The other half of the same rule: the per-seat payload mirrors the HTTP response shape exactly, so the client renders an opponent's turn with the same code that renders its own.
The ShouldBroadcastNow trap
ShouldBroadcast queues the broadcast. ShouldBroadcastNow sends it during the
request.
Every event in both our games implements ShouldBroadcastNow, and the reason is
worth stating plainly: if QUEUE_CONNECTION is set to anything other than
sync and no worker is actually running, ShouldBroadcast events go into the
queue table and stay there. Nothing errors. The app looks healthy. The game just
silently stops updating for the other player.
If you have a real worker, ShouldBroadcast is the better default for anything
that is not latency-critical. If you are not certain a worker is running, use
ShouldBroadcastNow for gameplay and find out later.
Treat broadcasting as best-effort either way. Reverb being briefly down should degrade a game to "the other player's screen updates late", never break the match. The authoritative state is in your database, and the client can always refetch.
Give every event a version
WebSockets are not a delivery guarantee. Sockets drop, reconnects miss events, and a client that was backgrounded on a phone comes back to a board that has moved on.
Every gameplay event we send carries a version number. The client compares it to the version it last applied. If there is a gap, it stops trusting the stream and refetches the match over plain HTTP, which is authoritative. Cheap to build, and it turns an entire class of "the board is wrong" bug reports into a self-healing case.
Running it in production
Reverb is a long-lived process. It needs a supervisor, and on a single box that means systemd.
[Service]
Type=simple
User=fopull
Restart=always
RestartSec=3
WorkingDirectory=/var/www/fopull
ExecStart=/usr/bin/php8.3 /var/www/fopull/artisan reverb:start --host=127.0.0.1 --port=8085
# WebSocket servers hold many open file descriptors.
LimitNOFILE=65536
Two things to copy. LimitNOFILE=65536, because every connected browser is an
open file descriptor and the default limit is not written with that in mind.
And binding to 127.0.0.1, because Reverb should never be the thing listening
on a public interface. Ours is reached through a
Cloudflare Tunnel that routes
the path ^/app/ to localhost:8085, so TLS terminates at Cloudflare and the
port is not open to the internet at all.
The host configuration that confuses everyone
Reverb has two sets of host settings and mixing them up produces a client that cannot connect with no useful error:
# Where the reverb:start process binds. Local.
REVERB_SERVER_HOST=127.0.0.1
REVERB_SERVER_PORT=8085
# The public endpoint. Advertised to clients, and baked into the JS bundle
# at build time by Vite.
REVERB_HOST=fopull.com
REVERB_PORT=443
REVERB_SCHEME=https
VITE_REVERB_HOST=fopull.com
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https
# Where PHP publishes events. Straight to the local process, so a broadcast
# does not take a round trip out to the CDN and back.
REVERB_PUBLISH_HOST=127.0.0.1
REVERB_PUBLISH_PORT=8085
REVERB_PUBLISH_SCHEME=http
The VITE_ values are compiled into your JavaScript when you run
npm run build. Changing them in .env without rebuilding assets changes
nothing, which is a fun half hour if you have not met it before.
The client, and letting it be absent
const key = import.meta.env.VITE_REVERB_APP_KEY;
if (key) {
window.Echo = new Echo({
broadcaster: 'reverb',
key,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: Number(import.meta.env.VITE_REVERB_PORT ?? 80),
wssPort: Number(import.meta.env.VITE_REVERB_PORT ?? 443),
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
}
Echo is only constructed when a key is present, and every feature that uses it checks first. Solo play against a bot needs no realtime at all, so on a build with no Reverb key those pages still work. That guard is also what keeps the test suite honest, since tests run with broadcasting off.
What we would skip
Redis scaling, until you need it. REVERB_SCALING_ENABLED=false is correct
for a single node, and a single node handles far more concurrent players than
most projects will ever have. Adding Redis before that point buys you a second
service to keep alive.
Whispers for gameplay. Client-to-client events never touch your server, so they never touch your rules. Fine for "opponent is typing", wrong for anything that decides who won.
Broadcasting the whole state every tick. Send what changed plus a version. The client can always ask for the rest.
Fopull LLC is a software studio in Knoxville, TN. We build real-time and game systems like this for other people, and run Connect Fo and Foduel on the setup above. If you want one built, tell us what you need.
Written by Ty Johnston at Fopull LLC, a software studio in Knoxville, TN. We build custom software and ship our own — Floptle, Storage Sifter, and more.