Channels + Stores

Connect Phoenix Channels to Svelte stores for reactive, multiplayer game state.

What you’ll build

A game channel that broadcasts player positions and updates a Svelte store. Players joining and leaving are tracked with Phoenix Presence.

The pattern

tostada projects ship with a working channel setup in client/src/lib/socket.ts and server/lib/tostada_web/channels/app_channel.ex. This guide shows how to extend it for game state:

  1. Create a new channel module on the server (GameChannel)
  2. Register it in UserSocket
  3. Create Svelte stores for game state
  4. Join the game channel from the client
  5. Wire channel events to store updates
  6. Track players with Presence

Server: Create GameChannel

Create server/lib/tostada_web/channels/game_channel.ex:

defmodule TostadaWeb.GameChannel do
  @moduledoc """
  Channel for multiplayer game rooms.
  """

  use TostadaWeb, :channel
  alias TostadaWeb.Presence

  @impl true
  def join("game:" <> room_id, _payload, socket) do
    send(self(), :after_join)
    {:ok, assign(socket, :room_id, room_id)}
  end

  @impl true
  def handle_info(:after_join, socket) do
    user_id = socket.assigns.user_id
    room_id = socket.assigns.room_id

    # Track this user's presence
    {:ok, _} = Presence.track(socket, user_id, %{
      online_at: inspect(System.system_time(:second))
    })

    # Send current presence state to the newly joined user
    push(socket, "presence_state", Presence.list(socket))

    {:noreply, socket}
  end

  @impl true
  def handle_in("player_move", %{"x" => x, "y" => y, "z" => z}, socket) do
    user_id = socket.assigns.user_id

    # Broadcast to everyone else in the room
    broadcast_from!(socket, "player_moved", %{
      user_id: user_id,
      position: %{x: x, y: y, z: z}
    })

    {:noreply, socket}
  end

  @impl true
  def handle_in("chat_message", %{"text" => text}, socket) do
    user_id = socket.assigns.user_id

    broadcast!(socket, "chat", %{
      user_id: user_id,
      text: text,
      timestamp: DateTime.utc_now()
    })

    {:reply, :ok, socket}
  end
end

Key points:

  • join("game:" <> room_id, ...) — Pattern match the room ID from the topic
  • :after_join message — Delays presence tracking until after join completes
  • Presence.track/3 — Tracks the user in this channel
  • broadcast_from!/3 — Sends to everyone except the sender
  • broadcast!/3 — Sends to everyone including sender

Server: Register GameChannel

Edit server/lib/tostada_web/channels/user_socket.ex to add the new channel route:

defmodule TostadaWeb.UserSocket do
  use Phoenix.Socket

  alias Tostada.Accounts

  # Channels
  channel "app:*", TostadaWeb.AppChannel
  channel "game:*", TostadaWeb.GameChannel  # <- Add this line

  # ... rest of the file unchanged
end

Client: Game stores

Create client/src/lib/game-store.ts:

import { writable, derived, type Writable } from 'svelte/store';
import type { Channel } from 'phoenix';

export interface Player {
  userId: string;
  position: { x: number; y: number; z: number };
  onlineAt: string;
}

export interface GameState {
  roomId: string | null;
  players: Map<string, Player>;
  chatMessages: ChatMessage[];
}

export interface ChatMessage {
  userId: string;
  text: string;
  timestamp: string;
}

export const gameState: Writable<GameState> = writable({
  roomId: null,
  players: new Map(),
  chatMessages: []
});

// Derived store: array of players for easy iteration
export const playerList = derived(gameState, ($state) => {
  return Array.from($state.players.values());
});

// Derived store: player count
export const playerCount = derived(playerList, ($players) => $players.length);

let gameChannel: Channel | null = null;

export function joinGameRoom(socket: any, roomId: string): Promise<void> {
  return new Promise((resolve, reject) => {
    gameChannel = socket.channel(`game:${roomId}`, {});

    // Listen for presence state (sent on join)
    gameChannel.on('presence_state', (state: any) => {
      gameState.update((s) => {
        const players = new Map<string, Player>();
        for (const [userId, presences] of Object.entries(state)) {
          const presence = (presences as any).metas[0];
          players.set(userId, {
            userId,
            position: { x: 0, y: 0, z: 0 }, // Default position
            onlineAt: presence.online_at
          });
        }
        return { ...s, roomId, players };
      });
    });

    // Listen for presence diffs (join/leave events)
    gameChannel.on('presence_diff', (diff: any) => {
      gameState.update((s) => {
        const players = new Map(s.players);

        // Handle joins
        for (const [userId, presences] of Object.entries(diff.joins)) {
          const presence = (presences as any).metas[0];
          players.set(userId, {
            userId,
            position: { x: 0, y: 0, z: 0 },
            onlineAt: presence.online_at
          });
        }

        // Handle leaves
        for (const userId of Object.keys(diff.leaves)) {
          players.delete(userId);
        }

        return { ...s, players };
      });
    });

    // Listen for player movements
    gameChannel.on('player_moved', (payload: any) => {
      gameState.update((s) => {
        const players = new Map(s.players);
        const player = players.get(payload.user_id);
        if (player) {
          player.position = payload.position;
          players.set(payload.user_id, player);
        }
        return { ...s, players };
      });
    });

    // Listen for chat messages
    gameChannel.on('chat', (payload: any) => {
      gameState.update((s) => ({
        ...s,
        chatMessages: [
          ...s.chatMessages,
          {
            userId: payload.user_id,
            text: payload.text,
            timestamp: payload.timestamp
          }
        ]
      }));
    });

    gameChannel
      .join()
      .receive('ok', () => resolve())
      .receive('error', (err: any) => reject(err));
  });
}

export function leaveGameRoom(): void {
  if (gameChannel) {
    gameChannel.leave();
    gameChannel = null;
    gameState.set({ roomId: null, players: new Map(), chatMessages: [] });
  }
}

export function sendPlayerMove(x: number, y: number, z: number): void {
  if (!gameChannel) return;
  gameChannel.push('player_move', { x, y, z });
}

export function sendChatMessage(text: string): void {
  if (!gameChannel) return;
  gameChannel.push('chat_message', { text });
}

Key points:

  • presence_state — Full presence snapshot on join
  • presence_diff — Incremental updates (joins/leaves)
  • Map<string, Player> — Efficient player lookup by user ID
  • Derived stores — Computed values that auto-update when gameState changes
  • Channel event listeners update the writable store, triggering reactivity

Client: Using the stores

In any Svelte component:

<script lang="ts">
  import { onMount } from 'svelte';
  import { connectSocket, socketStatus } from '$lib/socket';
  import { joinGameRoom, leaveGameRoom, playerList, playerCount, sendPlayerMove } from '$lib/game-store';

  let socket: any;

  onMount(async () => {
    await connectSocket();
    socket = (await import('$lib/socket')).default; // Access to phoenix socket
    await joinGameRoom(socket, 'room-123');

    return () => {
      leaveGameRoom();
    };
  });

  function handleMove() {
    const x = Math.random() * 10 - 5;
    const z = Math.random() * 10 - 5;
    sendPlayerMove(x, 0, z);
  }
</script>

<div>
  <p>Players online: {$playerCount}</p>

  <ul>
    {#each $playerList as player}
      <li>
        User {player.userId} at ({player.position.x.toFixed(1)}, {player.position.z.toFixed(1)})
      </li>
    {/each}
  </ul>

  <button onclick={handleMove}>Random Move</button>
</div>

The $ prefix auto-subscribes to the store. When the store updates, the component re-renders.


Testing it

  1. Start your servers: make dev (or mix phx.server + npm run dev separately)
  2. Open two browser windows at http://localhost:5173
  3. Log in as different users in each window
  4. Click “Random Move” in one window — the other window updates instantly

What’s happening

Client A                      Server                    Client B
   |                             |                          |
   |--join("game:room-123")----->|                          |
   |<----presence_state----------|                          |
   |                             |<---join("game:room-123")-|
   |<----presence_diff (join)----|                          |
   |                             |-----presence_diff------->|
   |                             |                          |
   |--player_move(x,y,z)-------->|                          |
   |                             |-----player_moved-------->|
   |                             |    (broadcast_from!)     |

Channel events flow into stores → stores trigger Svelte reactivity → UI updates


Next steps

  • Server authority: Move validation to the server (don’t trust client coordinates)
  • Tick loop: Broadcast state at fixed intervals instead of per-event
  • Input handling: Buffer inputs and send in batches for efficiency
  • Scene integration: Render player positions in Threlte 3D scenes

This pattern scales to any multiplayer game: replace player_move with your game’s actions, replace position with your game’s state shape.