Sucipto
Posted on December 22, 2023
As an open-source alternative to Firebase, Supabase has excellent features for building multiplayer real-time applications (which firebase doesn't have). Alongside the capability to listen to data changes in real-time, Supabase also has broadcast and presence features that can be used without involving the database at all.
Thanks to the Phoenix framework that powers the Supabase Realtime feature. Phoenix, an Elixir web framework, comes equipped with built-in broadcast (channel) and presence features.
With this feature, I can build real-time multiplayer apps without worrying about the backend handling websocket complexities. In this blog post, I will create a demo showcasing the utilization of Supabase Realtime features using SvelteKit.
Create SvelteKit Project
For a quick demo, I won't go into the tiny details of project setup (spoiler alert: the source code is included at the end of the post). In this project, I followed these steps:
- Create Svelte Kit Project
- Install Supabase JS SDK
- Install TailwindCSS for styling
The objective is to build a real-time multiplayer typing racer game, where players can type together and witness multiple cursors on the screen.
Player Presence
In this game, even guests (anonymous players) can join, and users can also log in using their Google accounts, thanks once again to Supabase for its Auth system. In this scenario, I need to generate a presence ID to assign to player data for more control, if we don't specify presence id, supabase will generate it randomly for you.
import { readable } from 'svelte/store';
export const presenceId = readable<string>(crypto.randomUUID());
export const room = writable<string>('global');
This presence ID will be utilized in channel creation using the presence key configuration:
$channel = supabase.channel(`room:${$room}`, {
config: {
presence: {
key: $presenceId,
},
broadcast: {
self: true,
},
},
});
I've also set the room ID in the global store, currently defaulting to global for now.
After channel initialized, we can setup event handler and then subscribe to channel.
$channel
?.on("presence", { event: "sync" }, () => {
const presences = $channel!.presenceState();
const presencePlayers = Object.entries(presences).map(([id, presence]) => {
const p = presence.at(0);
return {
id,
is_me: $presenceId == id,
...p,
} as Player;
});
players.set(presencePlayers);
})
.on("broadcast", { event: "typing" }, ({ payload }) => {
players.update((old) => {
return old.map((player) => {
if (player.id == payload.id) {
return {
...player,
wpm: payload.wpm,
word_index: payload.word_index,
letter_index: payload.letter_index,
};
}
return player;
});
});
})
.subscribe(async (status) => {
if (status == "SUBSCRIBED") {
const track = await $channel?.track({
online: new Date().toISOString(),
user_id: $user?.id,
user_name: $user?.user_metadata.full_name,
});
}
});
When the application loads, it subscribes to the $room
channel and tracks presence. Player names will appear to other players if they are already logged in; otherwise, they will be shown as guests.
During the presence sync event (when a new player joins the game), the player list will be updated.
When the typing
event is broadcasted, player data will be updated, including words per minute (WPM), word index, and letter index.
This realtime updated data will update the cursor UI, here is the snippet:
{#each display as { word, typed, correct, passed }, i}
<span
class="relative {correct && passed
? 'text-blue-500'
: !passed
? 'text-gray-500'
: 'text-red-500'}"
>
{#if currentWordIndex === i}
<Cursor {word} {typed} {wpm} name={$user?.user_metadata.full_name || 'Me'} />
{:else if playerIndex[i]}
<Cursor
{word}
typed={word}
me={false}
wpm={playerIndex[i].wpm ?? '~'}
name={playerIndex[i].user_name}
/>
{:else}
{word}
{/if}
</span>
{/each}
This will display both our cursor and another player's cursor. Here's the screenshot:
This demo project can be found at: Github Repository - Demo
Posted on December 22, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.