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.