Every multiplayer game needs the same boring infrastructure: WebSocket connections, room management, presence tracking, and some authoritative place to store game state. Traditionally that means running your own Socket.io server, managing reconnection logic, and spending more time on plumbing than on gameplay. Supabase Realtime changes the equation — it gives you managed WebSockets with three distinct primitives (Broadcast, Presence, and Postgres Changes) that map surprisingly well onto the patterns most browser-based multiplayer games actually need. Here's how I'd architect a multiplayer game with it, what each primitive is good for, and where the whole approach breaks down.
The three primitives
Supabase Realtime isn't one monolithic feature. It's three separate tools that share a single WebSocket connection per client. Understanding what each one does — and crucially, what it doesn't do — is the whole game.
- Broadcast sends ephemeral, low-latency messages between clients on the same channel. Think of it as a pub/sub pipe. Messages are fire-and-forget — they're not stored anywhere. If a client isn't connected when a message is sent, they miss it. Supabase's own benchmarks put median Broadcast latency at roughly 6 ms with a p95 around 28 ms — fast enough for most browser games.
- Presence tracks and synchronises shared state across connected
clients. Each client can set a state object (like
{ username: "alex", status: "ready" }), and every other client on the channel gets sync and join/leave events automatically. This is your lobby, your "who's online" list, your player-readiness indicator. - Postgres Changes streams real-time notifications whenever rows
in your database change. Insert a new move into a
movestable? Every subscribed client hears about it immediately. This gives you a persistent, authoritative record of game state that survives disconnections, page refreshes, and server restarts.
Mapping primitives to game patterns
The mistake most people make is trying to use one primitive for everything. Each one has a different job:
Architecture: a turn-based game
Turn-based games are the cleanest fit. The flow is straightforward:
- A player connects and joins a Supabase channel named after the game room
(e.g.
game:abc123). - Presence tracks who's in the room and whether they're ready.
- When it's your turn, you submit a move. The client calls a Supabase Edge
Function (or inserts directly into a
movestable with an RLS policy). - A Postgres function validates the move against the current board state. If it's legal, the row gets written. If not, it's rejected.
- Postgres Changes fires, notifying every subscribed client of the new move.
- Clients update their local UI from the authoritative database state.
The beauty of this pattern is that the database is the single source of truth. If a player disconnects and reconnects, they just query the current state from Postgres — no need for a separate "catch up" mechanism. And because validation lives in a Postgres function, there's no way for a client to submit an illegal move that gets accepted.
Architecture: a real-time action game
For faster games — think real-time movement, cursor battles, or casual arcade — the pattern flips. You can't write every position update to the database at 30 fps. Instead:
- Ephemeral state (player positions, animations, quick interactions) goes through Broadcast. Every client sends its position, every other client renders it. No database round-trip.
- Authoritative checkpoints (score changes, health updates, round transitions) still go through Postgres. A Supabase Edge Function handles validation.
- Presence handles join/leave and spectator tracking.
This hybrid approach keeps latency low for the things that need to feel instant, while still having a trustworthy record of everything that matters for game integrity. The client's local state might drift slightly between checkpoints, but that's normal — even AAA multiplayer games work this way.
Code: subscribing to a game room
Here's the core setup. One channel, all three primitives:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
const roomId = 'game:abc123'
// Join the channel
const channel = supabase.channel(roomId)
// 1. Presence — track who's in the room
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
console.log('Players online:', Object.keys(state))
})
// 2. Broadcast — receive ephemeral events
channel.on('broadcast', { event: 'player_move' }, ({ payload }) => {
// Update the other player's position locally
renderPlayer(payload.playerId, payload.x, payload.y)
})
// 3. Postgres Changes — authoritative game state
channel.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'games',
filter: `id=eq.${gameId}` },
({ new: row }) => {
// A validated state change happened — update the board
syncBoard(row.board_state)
}
)
// Subscribe and announce yourself
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({ username: 'tapiwa', status: 'ready' })
}
})
That's the entire connection setup. Three listeners on one channel, one WebSocket connection. Sending a Broadcast event is equally simple:
// Send position updates at ~20 fps (ephemeral, no DB)
setInterval(() => {
channel.send({
type: 'broadcast',
event: 'player_move',
payload: { playerId: myId, x: player.x, y: player.y }
})
}, 50)
Server-side validation with Edge Functions
Never trust the client for game logic. The pattern I'd use for validated moves:
// Supabase Edge Function: validate-move
import { createClient } from '@supabase/supabase-js'
Deno.serve(async (req) => {
const { gameId, playerId, move } = await req.json()
const supabase = createClient(
Deno.env.get('SUPABASE_URL'),
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
)
// Fetch current game state
const { data: game } = await supabase
.from('games').select('*').eq('id', gameId).single()
// Validate: is it this player's turn? Is the move legal?
if (game.current_turn !== playerId) {
return new Response(JSON.stringify({ error: 'Not your turn' }),
{ status: 403 })
}
if (!isLegalMove(game.board_state, move)) {
return new Response(JSON.stringify({ error: 'Illegal move' }),
{ status: 400 })
}
// Apply move and update DB (triggers Postgres Changes)
const newState = applyMove(game.board_state, move)
await supabase.from('games').update({
board_state: newState,
current_turn: getNextPlayer(game),
updated_at: new Date().toISOString()
}).eq('id', gameId)
return new Response(JSON.stringify({ ok: true }))
})
When this function writes to the games table, Postgres Changes
automatically notifies all subscribed clients. The client doesn't apply the move
optimistically — it waits for the database update, which guarantees both players
see the same state. For turn-based games, the extra 30–50 ms round-trip is
invisible.
Securing rooms with RLS
Since 2024, Supabase supports Row Level Security on Broadcast and Presence channels —
not just on database tables. Realtime creates a realtime.messages table
that you can write policies against:
-- Only authenticated players in this game can join the channel
CREATE POLICY "game_room_access" ON realtime.messages
FOR SELECT USING (
auth.uid() IN (
SELECT unnest(player_ids) FROM games
WHERE id = (realtime.messages.extension ->> 'channel')::uuid
)
);
This means a random client can't subscribe to someone else's game channel and snoop on their moves or spam Broadcast events. Combined with server-side move validation, you get a reasonably cheat-resistant architecture without running your own WebSocket server.
Where this approach breaks down
Supabase Realtime is not a game server. It's important to be honest about where the abstraction leaks:
- Fast-paced competitive games. If you need authoritative server-side physics simulation at 60 Hz, client-side prediction with server reconciliation, or sub-5 ms tick rates — you need a dedicated game server. Supabase's Broadcast is fast, but it's a relay, not a simulation host.
- Large numbers of players per room. Broadcast messages fan out to every subscriber on the channel. A 100-player room where everyone sends position updates at 20 Hz means 2,000 messages/second hitting every client. For large rooms, you'd need spatial partitioning or interest management that Supabase doesn't provide out of the box.
- Ordered, reliable delivery. Broadcast is fire-and-forget over WebSockets. Messages can arrive out of order or be lost during brief disconnects. For anything that must be reliable (like a chess move), go through Postgres, not Broadcast.
- Complex matchmaking. Supabase doesn't include matchmaking logic. You'll need an Edge Function or external service to pair players and create game rooms before Realtime enters the picture.
The sweet spot is indie multiplayer — turn-based games, card games, casual real-time games, social deduction, trivia, party games. The kind of thing where 20–50 ms latency is perfectly fine and you'd rather spend your time on gameplay than on WebSocket infrastructure.
Why this matters for solo developers
The traditional multiplayer stack for a browser game is painful: run a WebSocket server (Node.js + Socket.io, or a dedicated engine like Colyseus), manage connection state, handle reconnection logic, build your own presence system, figure out hosting and scaling. That's weeks of infrastructure work before you write a single line of game code.
Supabase Realtime compresses most of that into a managed service with a clean JavaScript API. You get WebSocket connections, room channels, presence tracking, and database-backed authoritative state out of the box. The free tier covers development and small launches. The Pro plan handles thousands of concurrent connections. And because your game state lives in Postgres, you get full SQL querying, backups, and migrations for free — no custom serialisation, no state file management.
It's not going to replace a dedicated game server for competitive real-time games. But for the vast majority of indie multiplayer projects — the ones that die in the infrastructure phase before anyone writes the fun parts — it removes enough friction to actually ship. And shipping is the hard part.