Putting It Together

Build a complete multiplayer game from scratch using all the patterns from the previous guides.

We’ll create Coin Collector: players move around a 3D space collecting coins. You’ll see other players in real-time, and the first to reach the target score wins.

This guide walks through the complete implementation — both server and client code, from supervision tree to 3D rendering.


Prerequisites

You should have read and understand:


Game Design

Objective: Collect coins before your opponents.

Rules:

  • 10 coins spawn randomly in the arena
  • Players move with arrow keys / WASD
  • Collision with a coin collects it
  • First player to 5 coins wins
  • Game resets after victory

Technical flow:

  1. Player joins lobby → sees player list with Presence
  2. Host starts game → server spawns coins, broadcasts game_start
  3. Players send movement inputs → server validates, updates positions
  4. Server ticks at 20 Hz → checks collisions, broadcasts state
  5. Client interpolates positions at 60 fps → smooth rendering
  6. Player reaches 5 coins → server broadcasts game_over → return to lobby

Server Implementation

Step 1: Game Channel

Create a dedicated channel for the game. Add to server/lib/tostada_web/channels/game_channel.ex:

defmodule TostadaWeb.GameChannel do
  @moduledoc """
  Multiplayer game channel for Coin Collector.
  Handles lobby, in-game inputs, and Presence tracking.
  """

  use TostadaWeb, :channel
  alias Tostada.GameServer
  alias TostadaWeb.Presence

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

  def join("game:" <> _room, _payload, _socket) do
    {:error, %{reason: "unauthorized"}}
  end

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

    # Track player in Presence
    {:ok, _} = Presence.track(socket, user_id, %{
      joined_at: System.system_time(:second)
    })

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

    # Send current game state
    game_state = GameServer.get_state()
    push(socket, "game_state", game_state)

    {:noreply, socket}
  end

  @impl true
  def handle_in("start_game", _payload, socket) do
    case GameServer.start_game() do
      {:ok, _state} -> {:reply, :ok, socket}
      {:error, reason} -> {:reply, {:error, %{reason: reason}}, socket}
    end
  end

  def handle_in("player_input", %{"action" => action} = _payload, socket) do
    user_id = socket.assigns.user_id
    GameServer.queue_input(user_id, action)
    {:noreply, socket}
  end

  def handle_in("player_inputs", %{"actions" => actions} = _payload, socket) do
    user_id = socket.assigns.user_id
    Enum.each(actions, fn action ->
      GameServer.queue_input(user_id, action)
    end)
    {:noreply, socket}
  end
end

Register the channel in server/lib/tostada_web/channels/user_socket.ex:

defmodule TostadaWeb.UserSocket do
  use Phoenix.Socket

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

  # ... rest of the file unchanged
end

Step 2: Game Server GenServer

Create the authoritative game server at server/lib/tostada/game_server.ex:

defmodule Tostada.GameServer do
  @moduledoc """
  Server-authoritative game state for Coin Collector.

  Manages:
  - Player positions and scores
  - Coin spawning and collection
  - Tick loop at 20 Hz
  - Input queue processing
  """

  use GenServer
  alias Phoenix.PubSub

  @tick_rate 50  # 20 Hz (50ms per tick)
  @move_speed 0.1
  @arena_size 10.0
  @coin_count 10
  @win_score 5

  # Client API

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end

  def get_state do
    :ets.lookup_element(:game_state, :state, 2)
  catch
    :error, :badarg -> initial_state()
  end

  def start_game do
    GenServer.call(__MODULE__, :start_game)
  end

  def queue_input(player_id, action) do
    GenServer.cast(__MODULE__, {:input, player_id, action, System.monotonic_time(:millisecond)})
  end

  # Server Callbacks

  @impl true
  def init(_opts) do
    :ets.new(:game_state, [:set, :named_table, :public, read_concurrency: true])
    :ets.insert(:game_state, {:state, initial_state()})
    {:ok, %{input_queue: []}}
  end

  @impl true
  def handle_call(:start_game, _from, state) do
    current = get_state()

    if current.phase == :lobby do
      new_state = %{
        phase: :playing,
        players: initialize_players(current.players),
        coins: spawn_coins(),
        winner: nil,
        tick: 0
      }

      :ets.insert(:game_state, {:state, new_state})
      broadcast_state(new_state)
      schedule_tick()

      {:reply, {:ok, new_state}, %{state | input_queue: []}}
    else
      {:reply, {:error, "game_already_started"}, state}
    end
  end

  @impl true
  def handle_cast({:input, player_id, action, queued_at}, state) do
    input = %{player_id: player_id, action: action, queued_at: queued_at}
    {:noreply, %{state | input_queue: [input | state.input_queue]}}
  end

  @impl true
  def handle_info(:tick, state) do
    current = get_state()

    if current.phase == :playing do
      # Process inputs (oldest first)
      sorted_inputs = Enum.sort_by(state.input_queue, & &1.queued_at)
      new_state = process_tick(current, sorted_inputs)

      # Check win condition
      final_state = check_win_condition(new_state)

      :ets.insert(:game_state, {:state, final_state})
      broadcast_state(final_state)

      # Schedule next tick only if still playing
      if final_state.phase == :playing do
        schedule_tick()
      end

      {:noreply, %{state | input_queue: []}}
    else
      {:noreply, state}
    end
  end

  # Private Helpers

  defp initial_state do
    %{
      phase: :lobby,
      players: %{},
      coins: [],
      winner: nil,
      tick: 0
    }
  end

  defp initialize_players(existing_players) do
    # Give each player a random starting position
    for {id, _data} <- existing_players, into: %{} do
      {id, %{
        position: random_position(),
        score: 0
      }}
    end
  end

  defp spawn_coins do
    for _ <- 1..@coin_count do
      %{
        id: generate_id(),
        position: random_position()
      }
    end
  end

  defp random_position do
    half = @arena_size / 2
    [
      :rand.uniform() * @arena_size - half,
      0.0,  # y is always ground level
      :rand.uniform() * @arena_size - half
    ]
  end

  defp generate_id, do: :crypto.strong_rand_bytes(8) |> Base.encode16()

  defp process_tick(state, inputs) do
    # Apply all inputs
    players = Enum.reduce(inputs, state.players, fn input, acc ->
      apply_input(acc, input.player_id, input.action)
    end)

    # Check coin collisions
    {updated_players, remaining_coins} = check_collisions(players, state.coins)

    %{state |
      players: updated_players,
      coins: remaining_coins,
      tick: state.tick + 1
    }
  end

  defp apply_input(players, player_id, action) do
    case Map.get(players, player_id) do
      nil -> players
      player ->
        new_pos = move_player(player.position, action)
        updated_player = %{player | position: clamp_position(new_pos)}
        Map.put(players, player_id, updated_player)
    end
  end

  defp move_player([x, y, z], action) do
    case action do
      "move_forward" -> [x, y, z - @move_speed]
      "move_back" -> [x, y, z + @move_speed]
      "move_left" -> [x - @move_speed, y, z]
      "move_right" -> [x + @move_speed, y, z]
      _ -> [x, y, z]
    end
  end

  defp clamp_position([x, y, z]) do
    half = @arena_size / 2
    [
      max(-half, min(half, x)),
      y,
      max(-half, min(half, z))
    ]
  end

  defp check_collisions(players, coins) do
    # For each player, check distance to each coin
    collection_radius = 0.5

    Enum.reduce(players, {players, coins}, fn {player_id, player}, {acc_players, acc_coins} ->
      # Find coins this player is touching
      {collected, remaining} = Enum.split_with(acc_coins, fn coin ->
        distance(player.position, coin.position) < collection_radius
      end)

      if length(collected) > 0 do
        # Award points
        updated_player = %{player | score: player.score + length(collected)}
        updated_players = Map.put(acc_players, player_id, updated_player)
        {updated_players, remaining}
      else
        {acc_players, remaining}
      end
    end)
  end

  defp distance([x1, _y1, z1], [x2, _y2, z2]) do
    dx = x2 - x1
    dz = z2 - z1
    :math.sqrt(dx * dx + dz * dz)
  end

  defp check_win_condition(state) do
    winner = Enum.find_value(state.players, fn {id, player} ->
      if player.score >= @win_score, do: id, else: nil
    end)

    if winner do
      %{state | phase: :game_over, winner: winner}
    else
      state
    end
  end

  defp schedule_tick do
    Process.send_after(self(), :tick, @tick_rate)
  end

  defp broadcast_state(state) do
    PubSub.broadcast(Tostada.PubSub, "game:lobby", {:game_state, state})
  end
end

Step 3: Add to Supervision Tree

In server/lib/tostada/application.ex, add GameServer to the children list:

defmodule Tostada.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      TostadaWeb.Telemetry,
      Tostada.Repo,
      {DNSCluster, query: Application.get_env(:tostada, :dns_cluster_query) || :ignore},
      {Phoenix.PubSub, name: Tostada.PubSub},
      TostadaWeb.Presence,
      Tostada.GameServer,  # Add this line
      TostadaWeb.Endpoint
    ]

    opts = [strategy: :one_for_one, name: Tostada.Supervisor]
    Supervisor.start_link(children, opts)
  end

  # ... rest unchanged
end

Client Implementation

Step 4: Game Store

Create client/src/lib/stores/game.ts:

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

export type GamePhase = 'lobby' | 'playing' | 'game_over';

export interface Player {
  position: [number, number, number];
  score: number;
}

export interface Coin {
  id: string;
  position: [number, number, number];
}

export interface GameState {
  phase: GamePhase;
  players: Record<string, Player>;
  coins: Coin[];
  winner: string | null;
  tick: number;
}

export const gameState = writable<GameState>({
  phase: 'lobby',
  players: {},
  coins: [],
  winner: null,
  tick: 0
});

export const previousGameState = writable<GameState | null>(null);
export const lastUpdateTime = writable<number>(Date.now());

// Derived stores for convenience
export const isPlaying = derived(gameState, $state => $state.phase === 'playing');
export const isGameOver = derived(gameState, $state => $state.phase === 'game_over');
export const playerCount = derived(gameState, $state => Object.keys($state.players).length);

// Presence tracking
export interface PresenceData {
  joined_at: number;
}

export const presences = writable<Record<string, PresenceData>>({});
export const presenceList = derived(presences, $p => Object.keys($p));

// Channel reference
export const gameChannel: Writable<Channel | null> = writable(null);

Step 5: Game Connection Module

Create client/src/lib/game.ts:

import { Socket, type Channel } from 'phoenix';
import { get } from 'svelte/store';
import {
  gameState,
  previousGameState,
  lastUpdateTime,
  presences,
  gameChannel,
  type GameState,
  type PresenceData
} from './stores/game';

let socket: Socket | null = null;
let channel: Channel | null = null;

export async function connectGame(): Promise<void> {
  // Fetch socket token
  const response = await fetch('/api/socket-token', { credentials: 'include' });
  const { token } = await response.json();

  // Resolve WebSocket URL
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  const wsUrl = `${protocol}//${window.location.host}/socket`;

  // Create socket
  socket = new Socket(wsUrl, { params: { token } });
  socket.connect();

  // Join game:lobby channel
  channel = socket.channel('game:lobby', {});
  gameChannel.set(channel);

  // Handle presence
  channel.on('presence_state', (state: Record<string, { metas: PresenceData[] }>) => {
    const formatted = Object.entries(state).reduce((acc, [id, { metas }]) => {
      acc[id] = metas[0];
      return acc;
    }, {} as Record<string, PresenceData>);
    presences.set(formatted);
  });

  channel.on('presence_diff', (diff: {
    joins: Record<string, { metas: PresenceData[] }>,
    leaves: Record<string, { metas: PresenceData[] }>
  }) => {
    const current = get(presences);

    // Add joins
    for (const [id, { metas }] of Object.entries(diff.joins)) {
      current[id] = metas[0];
    }

    // Remove leaves
    for (const id of Object.keys(diff.leaves)) {
      delete current[id];
    }

    presences.set(current);
  });

  // Handle game state updates
  channel.on('game_state', (state: GameState) => {
    previousGameState.set(get(gameState));
    gameState.set(state);
    lastUpdateTime.set(Date.now());
  });

  await new Promise<void>((resolve, reject) => {
    channel?.join()
      .receive('ok', () => resolve())
      .receive('error', (err) => reject(err));
  });
}

export function disconnectGame(): void {
  channel?.leave();
  socket?.disconnect();
  gameChannel.set(null);
}

export function startGame(): void {
  channel?.push('start_game', {});
}

export function sendInput(action: string): void {
  channel?.push('player_input', { action });
}

export function sendInputBatch(actions: string[]): void {
  if (actions.length === 0) return;
  channel?.push('player_inputs', { actions });
}

Step 6: Input Handler

Create client/src/lib/input.ts:

import { sendInputBatch } from './game';

const keys = new Set<string>();
let inputBatch: string[] = [];

// Map keys to game actions
const keyMap: Record<string, string> = {
  'ArrowUp': 'move_forward',
  'KeyW': 'move_forward',
  'ArrowDown': 'move_back',
  'KeyS': 'move_back',
  'ArrowLeft': 'move_left',
  'KeyA': 'move_left',
  'ArrowRight': 'move_right',
  'KeyD': 'move_right',
};

export function startInputCapture(): void {
  window.addEventListener('keydown', handleKeyDown);
  window.addEventListener('keyup', handleKeyUp);
}

export function stopInputCapture(): void {
  window.removeEventListener('keydown', handleKeyDown);
  window.removeEventListener('keyup', handleKeyUp);
  keys.clear();
  inputBatch = [];
}

function handleKeyDown(e: KeyboardEvent): void {
  const action = keyMap[e.code];
  if (!action) return;

  e.preventDefault();
  keys.add(e.code);
}

function handleKeyUp(e: KeyboardEvent): void {
  keys.delete(e.code);
}

// Call this every frame to gather inputs
export function pollInputs(): void {
  const actions: string[] = [];

  for (const code of keys) {
    const action = keyMap[code];
    if (action) actions.push(action);
  }

  inputBatch.push(...actions);
}

// Call this at tick rate (e.g. every 50ms) to send batched inputs
export function flushInputs(): void {
  if (inputBatch.length > 0) {
    sendInputBatch(inputBatch);
    inputBatch = [];
  }
}

Step 7: Lobby Scene

Create client/src/routes/game/LobbyScene.svelte:

<script lang="ts">
  import { T } from '@threlte/core';
  import { presenceList, playerCount } from '$lib/stores/game';
  import { startGame } from '$lib/game';
</script>

<!-- Simple lobby scene with floating text -->
<T.PerspectiveCamera makeDefault position={[0, 2, 5]} fov={45} oncreate={(ref) => ref.lookAt(0, 0, 0)} />

<T.AmbientLight intensity={0.5} />
<T.DirectionalLight position={[5, 5, 5]} intensity={1} />

<!-- Ground plane -->
<T.Mesh rotation={[-Math.PI / 2, 0, 0]}>
  <T.PlaneGeometry args={[20, 20]} />
  <T.MeshStandardMaterial color="#1a1a1a" />
</T.Mesh>

<!-- Lobby UI overlay (rendered outside 3D context) -->
<div class="lobby-ui">
  <h1>Coin Collector</h1>
  <p>Players online: {$playerCount}</p>
  <p>Waiting in lobby: {$presenceList.length}</p>

  <button onclick={() => startGame()}>Start Game</button>

  <div class="rules">
    <p>• Collect 5 coins to win</p>
    <p>• Use arrow keys or WASD to move</p>
  </div>
</div>

<style>
  .lobby-ui {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    text-align: center;
    color: white;
    z-index: 10;
  }

  h1 {
    font-size: 3rem;
    margin-bottom: 1rem;
    color: #f59e0b;
  }

  button {
    margin-top: 2rem;
    padding: 1rem 2rem;
    font-size: 1.2rem;
    background: #f59e0b;
    color: black;
    border: none;
    border-radius: 8px;
    cursor: pointer;
  }

  button:hover {
    background: #d97706;
  }

  .rules {
    margin-top: 2rem;
    opacity: 0.7;
  }
</style>

Step 8: Gameplay Scene

Create client/src/routes/game/GameplayScene.svelte:

<script lang="ts">
  import { T, useTask } from '@threlte/core';
  import { get } from 'svelte/store';
  import { gameState, previousGameState, lastUpdateTime } from '$lib/stores/game';
  import { pollInputs, flushInputs } from '$lib/input';

  // Interpolation state
  let interpolatedPlayers = $state<Record<string, [number, number, number]>>({});
  let tickAccumulator = $state(0);
  const tickInterval = 50; // 20 Hz

  // Player colors (assign random colors to each player)
  const playerColors: Record<string, string> = {};
  function getPlayerColor(id: string): string {
    if (!playerColors[id]) {
      const hue = Math.random() * 360;
      playerColors[id] = `hsl(${hue}, 70%, 60%)`;
    }
    return playerColors[id];
  }

  // Interpolation with useTask (runs every frame)
  useTask((delta) => {
    const deltaMs = delta * 1000;

    // Poll inputs every frame for responsiveness
    pollInputs();

    // Accumulate time for tick-rate input sending
    tickAccumulator += deltaMs;
    if (tickAccumulator >= tickInterval) {
      flushInputs();
      tickAccumulator = 0;
    }

    // Interpolate positions
    const current = get(gameState);
    const previous = get(previousGameState);
    const updateTime = get(lastUpdateTime);

    if (!previous) {
      // No previous state, use current positions directly
      interpolatedPlayers = Object.entries(current.players).reduce((acc, [id, player]) => {
        acc[id] = player.position;
        return acc;
      }, {} as Record<string, [number, number, number]>);
      return;
    }

    // Calculate interpolation alpha
    const elapsed = Date.now() - updateTime;
    const alpha = Math.min(elapsed / tickInterval, 1.0);

    // Lerp each player position
    interpolatedPlayers = Object.entries(current.players).reduce((acc, [id, player]) => {
      const prevPos = previous.players[id]?.position ?? player.position;
      const currPos = player.position;

      acc[id] = [
        prevPos[0] + (currPos[0] - prevPos[0]) * alpha,
        prevPos[1] + (currPos[1] - prevPos[1]) * alpha,
        prevPos[2] + (currPos[2] - prevPos[2]) * alpha,
      ];
      return acc;
    }, {} as Record<string, [number, number, number]>);
  });
</script>

<T.PerspectiveCamera
  makeDefault
  position={[0, 12, 8]}
  fov={50}
  oncreate={(ref) => ref.lookAt(0, 0, 0)}
/>

<T.AmbientLight intensity={0.6} />
<T.DirectionalLight position={[10, 10, 5]} intensity={1.2} />

<!-- Arena floor -->
<T.Mesh rotation={[-Math.PI / 2, 0, 0]}>
  <T.PlaneGeometry args={[10, 10]} />
  <T.MeshStandardMaterial color="#2a2a2a" />
</T.Mesh>

<!-- Players -->
{#each Object.entries(interpolatedPlayers) as [id, position]}
  <T.Mesh position={[position[0], 0.3, position[2]]}>
    <T.CapsuleGeometry args={[0.3, 0.3, 8, 16]} />
    <T.MeshStandardMaterial color={getPlayerColor(id)} metalness={0.3} roughness={0.5} />
  </T.Mesh>
{/each}

<!-- Coins -->
{#each $gameState.coins as coin}
  <T.Mesh position={coin.position} rotation={[0, 0, 0]}>
    <T.CylinderGeometry args={[0.3, 0.3, 0.1, 16]} />
    <T.MeshStandardMaterial color="#fbbf24" metalness={0.8} roughness={0.2} />
  </T.Mesh>
{/each}

<!-- HUD -->
<div class="hud">
  <div class="scores">
    {#each Object.entries($gameState.players) as [id, player]}
      <div class="score">
        <span class="color-dot" style="background: {getPlayerColor(id)}"></span>
        Player {id.slice(0, 4)}: {player.score}/5
      </div>
    {/each}
  </div>
</div>

<style>
  .hud {
    position: absolute;
    top: 1rem;
    left: 1rem;
    color: white;
    font-family: monospace;
    z-index: 10;
  }

  .scores {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
  }

  .score {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    background: rgba(0, 0, 0, 0.6);
    padding: 0.5rem 1rem;
    border-radius: 4px;
  }

  .color-dot {
    width: 12px;
    height: 12px;
    border-radius: 50%;
  }
</style>

Step 9: Game Over Scene

Create client/src/routes/game/GameOverScene.svelte:

<script lang="ts">
  import { T } from '@threlte/core';
  import { gameState } from '$lib/stores/game';

  const winnerSlice = $derived($gameState.winner?.slice(0, 6) ?? 'Unknown');
</script>

<T.PerspectiveCamera makeDefault position={[0, 2, 5]} fov={45} oncreate={(ref) => ref.lookAt(0, 0, 0)} />

<T.AmbientLight intensity={0.5} />
<T.DirectionalLight position={[5, 5, 5]} intensity={1} />

<!-- Ground -->
<T.Mesh rotation={[-Math.PI / 2, 0, 0]}>
  <T.PlaneGeometry args={[20, 20]} />
  <T.MeshStandardMaterial color="#1a1a1a" />
</T.Mesh>

<!-- Victory UI -->
<div class="victory-ui">
  <h1>🎉 Victory! 🎉</h1>
  <p class="winner">Player {winnerSlice} wins!</p>

  <div class="final-scores">
    <h2>Final Scores</h2>
    {#each Object.entries($gameState.players) as [id, player]}
      <div class="score" class:winner={id === $gameState.winner}>
        Player {id.slice(0, 6)}: {player.score} coins
      </div>
    {/each}
  </div>

  <p class="note">Returning to lobby...</p>
</div>

<style>
  .victory-ui {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    text-align: center;
    color: white;
    z-index: 10;
  }

  h1 {
    font-size: 3rem;
    color: #fbbf24;
    margin-bottom: 1rem;
  }

  .winner {
    font-size: 1.5rem;
    margin-bottom: 2rem;
  }

  .final-scores {
    background: rgba(0, 0, 0, 0.6);
    padding: 1rem 2rem;
    border-radius: 8px;
    margin-bottom: 2rem;
  }

  h2 {
    font-size: 1.2rem;
    margin-bottom: 1rem;
    opacity: 0.8;
  }

  .score {
    padding: 0.5rem;
    margin: 0.25rem 0;
  }

  .score.winner {
    color: #fbbf24;
    font-weight: bold;
  }

  .note {
    opacity: 0.6;
    font-style: italic;
  }
</style>

Step 10: Scene Director

Create client/src/routes/game/SceneDirector.svelte:

<script lang="ts">
  import { Canvas } from '@threlte/core';
  import { gameState } from '$lib/stores/game';
  import LobbyScene from './LobbyScene.svelte';
  import GameplayScene from './GameplayScene.svelte';
  import GameOverScene from './GameOverScene.svelte';
  import { startInputCapture, stopInputCapture } from '$lib/input';
  import { onMount, onDestroy } from 'svelte';

  // Start/stop input capture based on game phase
  $effect(() => {
    if ($gameState.phase === 'playing') {
      startInputCapture();
      return () => stopInputCapture();
    } else {
      stopInputCapture();
    }
  });
</script>

<div class="game-canvas">
  <Canvas>
    {#if $gameState.phase === 'lobby'}
      <LobbyScene />
    {:else if $gameState.phase === 'playing'}
      <GameplayScene />
    {:else if $gameState.phase === 'game_over'}
      <GameOverScene />
    {/if}
  </Canvas>
</div>

<style>
  .game-canvas {
    position: fixed;
    inset: 0;
    background: #0a0a0a;
  }
</style>

Step 11: Main Game Page

Create client/src/routes/game/+page.svelte:

<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import { connectGame, disconnectGame } from '$lib/game';
  import SceneDirector from './SceneDirector.svelte';

  let connected = $state(false);
  let error = $state<string | null>(null);

  onMount(async () => {
    try {
      await connectGame();
      connected = true;
    } catch (err) {
      error = err instanceof Error ? err.message : 'Connection failed';
      console.error('Failed to connect:', err);
    }
  });

  onDestroy(() => {
    disconnectGame();
  });
</script>

{#if error}
  <div class="error">
    <h1>Connection Error</h1>
    <p>{error}</p>
  </div>
{:else if !connected}
  <div class="loading">
    <p>Connecting to game server...</p>
  </div>
{:else}
  <SceneDirector />
{/if}

<style>
  .error, .loading {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 100vh;
    background: #0a0a0a;
    color: white;
  }

  .error h1 {
    color: #ef4444;
    margin-bottom: 1rem;
  }
</style>

Testing the Game

1. Start the development servers

If you have tmux installed, run both servers together:

make dev

Or run them in separate terminals:

# Terminal 1: Start Phoenix server
make dev.server

# Terminal 2: Start Vite dev server
make dev.client

2. Open multiple browser windows

Navigate to http://localhost:5173/game in 2+ browser tabs to simulate multiple players.

3. Play the game

  • You’ll start in the lobby and see player count increase
  • Click “Start Game” in any tab
  • Use arrow keys or WASD to move
  • Collect coins (yellow cylinders)
  • First to 5 coins wins
  • Game returns to lobby after victory

What We Built

This complete multiplayer game demonstrates all 5 core patterns:

  1. Channels + StoresGameChannel communicates with Svelte stores, Presence tracks players
  2. Server AuthorityGameServer validates moves, checks collisions, enforces rules
  3. Tick Loop — Server updates at 20 Hz, client interpolates at 60 fps
  4. Input Handling — Keyboard capture → batched sends → server queue → tick processing
  5. Scene ManagementSceneDirector switches between lobby/gameplay/game_over scenes

Data flow recap:

Keyboard → input.ts → game.ts → GameChannel → GameServer
    ↓
GameServer tick → ETS update → PubSub broadcast
    ↓
GameChannel → game.ts stores → Scene components → Render

You now have a foundation for building any multiplayer game with tostada — from turn-based strategy to fast-paced action.


Next Steps

Extend the game:

  • Add power-ups (speed boost, double points)
  • Implement teams (red vs blue)
  • Add obstacles or moving platforms
  • Create multiple arena maps
  • Add sound effects and particle effects

Production hardening:

  • Handle disconnections gracefully (save player state, allow rejoin)
  • Add reconnection logic with state sync
  • Implement matchmaking (player skill, region)
  • Add spectator mode
  • Monitor server performance with Telemetry

Learn more:


Ready to build your multiplayer game? Start with this foundation and iterate!