Code Snippets
Copy-paste recipes for common tostada operations. Each snippet is self-contained and shows both the server (Elixir) and client (TypeScript/Svelte) sides where applicable.
Socket & Channels
Connect to the Socket
The existing socket.ts handles connection. Import and call it from any component:
import { connectSocket, socketStatus } from '$lib/socket';
// Connect (fetches token, opens WebSocket, joins app:lobby)
await connectSocket();
// React to connection state
$effect(() => {
console.log('Socket:', $socketStatus);
// 'disconnected' | 'connecting' | 'connected' | 'error'
}); Add a New Channel Topic
Server — Register the topic in user_socket.ex, create the channel module:
# lib/tostada_web/channels/user_socket.ex
channel "game:*", TostadaWeb.GameChannel # Add alongside existing "app:*" # lib/tostada_web/channels/game_channel.ex
defmodule TostadaWeb.GameChannel do
use Phoenix.Channel
@impl true
def join("game:" <> room_id, _payload, socket) do
{:ok, assign(socket, :room_id, room_id)}
end
@impl true
def handle_in("move", %{"direction" => dir}, socket) do
broadcast_from!(socket, "player_moved", %{
user_id: socket.assigns.user_id,
direction: dir
})
{:noreply, socket}
end
end Client — Join the channel after socket connects:
import { Socket } from 'phoenix';
let socket: Socket;
let gameChannel: any;
export function joinGame(roomId: string) {
gameChannel = socket.channel(`game:${roomId}`, {});
gameChannel.join()
.receive('ok', () => console.log('Joined game'))
.receive('error', (err) => console.error('Join failed:', err));
gameChannel.on('player_moved', (payload) => {
console.log(payload.user_id, 'moved', payload.direction);
});
} Create a Store from Channel Events
Wire a channel event directly into a Svelte writable store:
import { writable } from 'svelte/store';
// Typed game state
interface GameState {
players: Record<string, { x: number; y: number }>;
phase: 'lobby' | 'playing' | 'ended';
}
export const gameState = writable<GameState>({
players: {},
phase: 'lobby'
});
// Call after channel join
export function bindChannelToStore(channel: any) {
channel.on('state_update', (payload: GameState) => {
gameState.set(payload);
});
} Presence
Track Players in a Room
Server — Track on join, broadcast presence state:
# In your GameChannel
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
Presence.track(socket, socket.assigns.user_id, %{
joined_at: System.system_time(:second),
username: "Player #{socket.assigns.user_id}"
})
push(socket, "presence_state", Presence.list(socket))
{:noreply, socket}
end Client — Sync presence into a store:
import { Presence } from 'phoenix';
import { writable, derived } from 'svelte/store';
const presenceState = writable<Record<string, any>>({});
export const players = derived(presenceState, ($state) =>
Object.entries($state).map(([id, { metas }]) => ({
id,
...metas[0]
}))
);
export function syncPresence(channel: any) {
const presence = new Presence(channel);
presence.onSync(() => {
presenceState.set(presence.list());
});
} GenServer & Supervision
Add a GameServer to the Supervision Tree
# lib/tostada/game_server.ex
defmodule Tostada.GameServer do
use GenServer
def start_link(room_id) do
GenServer.start_link(__MODULE__, room_id, name: via(room_id))
end
defp via(room_id), do: {:via, Registry, {Tostada.GameRegistry, room_id}}
@impl true
def init(room_id) do
{:ok, %{room_id: room_id, players: %{}, phase: :lobby}}
end
# Public API
def get_state(room_id) do
GenServer.call(via(room_id), :get_state)
end
@impl true
def handle_call(:get_state, _from, state) do
{:reply, state, state}
end
end Register the Registry in application.ex:
# lib/tostada/application.ex — add to children list before Endpoint
{Registry, keys: :unique, name: Tostada.GameRegistry}, Start a GameServer Dynamically
Use a DynamicSupervisor for on-demand game rooms:
# lib/tostada/application.ex — add to children list
{DynamicSupervisor, name: Tostada.GameSupervisor, strategy: :one_for_one}, # Start a game room from a channel join
def join("game:" <> room_id, _payload, socket) do
case DynamicSupervisor.start_child(
Tostada.GameSupervisor,
{Tostada.GameServer, room_id}
) do
{:ok, _pid} -> {:ok, assign(socket, :room_id, room_id)}
{:error, {:already_started, _pid}} -> {:ok, assign(socket, :room_id, room_id)}
end
end ETS for Fast Reads
Set Up an ETS Table in a GenServer
@impl true
def init(room_id) do
table = :ets.new(:"game_#{room_id}", [
:set, :public, read_concurrency: true
])
:ets.insert(table, {:state, %{players: %{}, phase: :lobby}})
{:ok, %{room_id: room_id, table: table}}
end
# Fast read (any process can call this)
def get_state(room_id) do
[{:state, state}] = :ets.lookup(:"game_#{room_id}", :state)
state
end
# Write through GenServer (serialized)
def handle_cast({:update_player, user_id, pos}, state) do
current = get_state(state.room_id)
updated = put_in(current, [:players, user_id], pos)
:ets.insert(state.table, {:state, updated})
{:noreply, state}
end Tick Loop
Fixed-Timestep Server Loop
@tick_rate 50 # 20 ticks per second
@impl true
def init(room_id) do
Process.send_after(self(), :tick, @tick_rate)
{:ok, %{room_id: room_id, inputs: [], tick: 0}}
end
@impl true
def handle_info(:tick, state) do
# Process queued inputs
new_state = Enum.reduce(state.inputs, state, &process_input/2)
# Broadcast to clients
Phoenix.PubSub.broadcast(
Tostada.PubSub,
"game:#{state.room_id}",
{:state_update, new_state}
)
# Schedule next tick
Process.send_after(self(), :tick, @tick_rate)
{:noreply, %{new_state | inputs: [], tick: state.tick + 1}}
end
# Queue inputs (non-blocking)
@impl true
def handle_cast({:input, user_id, action}, state) do
{:noreply, %{state | inputs: [{user_id, action} | state.inputs]}}
end Client Interpolation with useTask
import { useTask } from '@threlte/core';
import { get } from 'svelte/store';
import { gameState } from '$lib/stores/game';
let previousState = $state(get(gameState));
let currentState = $state(get(gameState));
let elapsed = $state(0);
const tickInterval = 50; // Match server tick rate (ms)
// Capture new states as they arrive
$effect(() => {
const state = $gameState;
previousState = currentState;
currentState = state;
elapsed = 0;
});
// Interpolate between states each frame
useTask((delta) => {
elapsed += delta * 1000;
const alpha = Math.min(elapsed / tickInterval, 1);
// Lerp player positions
for (const [id, player] of Object.entries(currentState.players)) {
const prev = previousState.players[id];
if (prev) {
const x = prev.x + (player.x - prev.x) * alpha;
const y = prev.y + (player.y - prev.y) * alpha;
// Apply to mesh position...
}
}
}); Input Handling
Keyboard Capture with Semantic Actions
const keyMap: Record<string, string> = {
ArrowUp: 'move_north',
ArrowDown: 'move_south',
ArrowLeft: 'move_west',
ArrowRight: 'move_east',
' ': 'jump',
w: 'move_north',
a: 'move_west',
s: 'move_south',
d: 'move_east'
};
const activeKeys = new Set<string>();
function onKeyDown(e: KeyboardEvent) {
const action = keyMap[e.key];
if (action && !activeKeys.has(e.key)) {
activeKeys.add(e.key);
channel.push('input', { action, type: 'start' });
}
}
function onKeyUp(e: KeyboardEvent) {
const action = keyMap[e.key];
if (action) {
activeKeys.delete(e.key);
channel.push('input', { action, type: 'stop' });
}
} Batched Input Sending (Tick-Aligned)
let inputBuffer: Array<{ action: string; type: string }> = [];
function queueInput(action: string, type: string) {
inputBuffer.push({ action, type });
}
// Send at tick rate, not per-keypress
useTask((delta) => {
accumulated += delta * 1000;
if (accumulated >= 50 && inputBuffer.length > 0) {
channel.push('input_batch', { inputs: inputBuffer });
inputBuffer = [];
accumulated = 0;
}
}); Scene Management
Store-Driven Scene Switching
import { writable, derived } from 'svelte/store';
type GamePhase = 'lobby' | 'playing' | 'results';
export const gamePhase = writable<GamePhase>('lobby');
export const isPlaying = derived(gamePhase, ($p) => $p === 'playing'); <script lang="ts">
import { gamePhase } from '$lib/stores/game';
import LobbyScene from './LobbyScene.svelte';
import GameScene from './GameScene.svelte';
import ResultsScene from './ResultsScene.svelte';
</script>
{#if $gamePhase === 'lobby'}
<LobbyScene />
{:else if $gamePhase === 'playing'}
<GameScene />
{:else if $gamePhase === 'results'}
<ResultsScene />
{/if} Basic Threlte Scene Template
<script lang="ts">
import { T, useTask } from '@threlte/core';
import { OrbitControls } from '@threlte/extras';
import type { Mesh } from 'three';
let mesh: Mesh | undefined = $state();
let t = $state(0);
useTask((delta) => {
t += delta;
if (mesh) {
mesh.rotation.y += delta * 0.5;
mesh.position.y = Math.sin(t) * 0.15;
}
});
</script>
<T.PerspectiveCamera makeDefault position={[0, 1.2, 4]} fov={45}>
<OrbitControls enableZoom={false} enablePan={false} />
</T.PerspectiveCamera>
<T.AmbientLight intensity={0.6} />
<T.DirectionalLight position={[5, 5, 3]} intensity={1} />
<T.Mesh bind:ref={mesh}>
<T.BoxGeometry args={[1, 1, 1]} />
<T.MeshStandardMaterial color="#f5a623" />
</T.Mesh> PubSub Broadcasting
Broadcast from GenServer to Channels
# In GameServer — broadcast state to all clients in the room
defp broadcast_state(state) do
Phoenix.PubSub.broadcast(
Tostada.PubSub,
"game:#{state.room_id}",
{:state_update, sanitize(state)}
)
end
# In GameChannel — subscribe on join, forward to client
@impl true
def join("game:" <> room_id, _payload, socket) do
Phoenix.PubSub.subscribe(Tostada.PubSub, "game:#{room_id}")
{:ok, assign(socket, :room_id, room_id)}
end
@impl true
def handle_info({:state_update, state}, socket) do
push(socket, "state_update", state)
{:noreply, socket}
end Common Make Commands
make install # Install all dependencies (server + client)
make dev # Start both servers (tmux split pane)
make dev.server # Phoenix only (localhost:4000)
make dev.client # SvelteKit only (localhost:5173)
make db.reset # Drop, create, migrate database
make test # Run all tests (server + client)
make models.build # GLTF → Svelte components
make build # Production build Each snippet extends the existing tostada template code. See the Guides for full walkthroughs that connect these patterns together.