DEV Community

Sucipto
Sucipto

Posted on

Build Multiplayer Cursors with Supabase & SvelteKit

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:

  1. Create Svelte Kit Project
  2. Install Supabase JS SDK
  3. 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');
Enter fullscreen mode Exit fullscreen mode

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,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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,
      });
    }
  });

Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

This will display both our cursor and another player's cursor. Here's the screenshot:

Multiplayer Cursor

This demo project can be found at: Github Repository - Demo

Top comments (0)