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:
- Channels + Stores — Phoenix channels ↔ Svelte stores
- Server Authority — GameServer with ETS and validation
- Tick Loop — Fixed-timestep updates and interpolation
- Input Handling — Keyboard/gamepad capture and batching
- Scene Management — Store-driven scene switching
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:
- Player joins lobby → sees player list with Presence
- Host starts game → server spawns coins, broadcasts game_start
- Players send movement inputs → server validates, updates positions
- Server ticks at 20 Hz → checks collisions, broadcasts state
- Client interpolates positions at 60 fps → smooth rendering
- 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:
- Channels + Stores —
GameChannelcommunicates with Svelte stores, Presence tracks players - Server Authority —
GameServervalidates moves, checks collisions, enforces rules - Tick Loop — Server updates at 20 Hz, client interpolates at 60 fps
- Input Handling — Keyboard capture → batched sends → server queue → tick processing
- Scene Management —
SceneDirectorswitches 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!