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:
- Create a new channel module on the server (
GameChannel) - Register it in
UserSocket - Create Svelte stores for game state
- Join the game channel from the client
- Wire channel events to store updates
- 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_joinmessage — Delays presence tracking until after join completesPresence.track/3— Tracks the user in this channelbroadcast_from!/3— Sends to everyone except the senderbroadcast!/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 joinpresence_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
- Start your servers:
make dev(ormix phx.server+npm run devseparately) - Open two browser windows at
http://localhost:5173 - Log in as different users in each window
- 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.