Building real-time multiplayer games with Supabase Realtime

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 moves table? 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:

Supabase Realtime — Primitive ↔ Game Feature Map Broadcast ephemeral, fast Player cursor positions · Animations · Quick reactions Chat messages · Typing indicators · Real-time pings fire-and-forget — not stored, missed if offline Presence synced state Lobby "who's online" · Player readiness · Team picks Spectator count · Active participant list auto join/leave events — state resets on disconnect Postgres Changes persistent, auth Board state · Move history · Scores · Match results Turn progression · Game config · Persistent chat logs survives disconnect — DB is the source of truth Design rule: Broadcast = megaphone, Presence = roster, Postgres = referee Ephemeral events on Broadcast, player status on Presence, authoritative state in the database.
Each primitive solves a different layer of the multiplayer problem.

Architecture: a turn-based game

Turn-based games are the cleanest fit. The flow is straightforward:

  1. A player connects and joins a Supabase channel named after the game room (e.g. game:abc123).
  2. Presence tracks who's in the room and whether they're ready.
  3. When it's your turn, you submit a move. The client calls a Supabase Edge Function (or inserts directly into a moves table with an RLS policy).
  4. A Postgres function validates the move against the current board state. If it's legal, the row gets written. If not, it's rejected.
  5. Postgres Changes fires, notifying every subscribed client of the new move.
  6. 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:

  1. Ephemeral state (player positions, animations, quick interactions) goes through Broadcast. Every client sends its position, every other client renders it. No database round-trip.
  2. Authoritative checkpoints (score changes, health updates, round transitions) still go through Postgres. A Supabase Edge Function handles validation.
  3. 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.

Related
→ Supabase Realtime docs → Broadcast reference → Supabase Realtime benchmarks → ZimBet — real-time multiplayer betting platform
Is Supabase Realtime fast enough for multiplayer games?

It depends on the game type. Supabase's own benchmarks show a median Broadcast latency of about 6 ms with a p95 of 28 ms — more than adequate for turn-based, card, strategy, and casual real-time games. For fast-paced shooters or fighting games that need sub-5 ms tick rates and client-side prediction, you'll likely need a dedicated game server with raw WebSockets or UDP. Supabase Realtime sits in the sweet spot for games where 30–60 ms round-trip latency is acceptable.

Should I use Broadcast or Postgres Changes for game state?

Use both, but for different purposes. Broadcast is best for ephemeral, high-frequency events — player cursor positions, animations, chat messages, quick reactions — anything that doesn't need to survive a page refresh. Postgres Changes (or direct Postgres writes verified by the server) should be the authoritative source of truth for game state — board positions, scores, turn history. The database is your referee; Broadcast is your megaphone.

How do I prevent cheating in a Supabase multiplayer game?

Never trust the client for game logic. Validate every move server-side — either with a Postgres function (triggered on insert/update) or a Supabase Edge Function that checks the move against the current game state before writing it to the database. Row Level Security (RLS) policies restrict who can read and write to game tables, and since 2024 Supabase also supports RLS on Broadcast and Presence channels. Treat the client as an untrusted input device and the database as the referee.

Can Supabase handle thousands of concurrent game rooms?

Yes. Supabase Realtime is built on a distributed Elixir cluster (Phoenix Channels) and supports hundreds of thousands of concurrent connections. Each game room is just a channel. The practical ceiling depends on your Supabase plan's connection and message limits, but for most indie games the free tier handles development easily, and the Pro plan covers thousands of concurrent players across many rooms.