Getting Started

Build multiplayer 3D games with Phoenix (Elixir) + SvelteKit (TypeScript) + Threlte (Three.js).

This guide explains how to install tostada, understand its dual-server architecture, and get your first game running.


Prerequisites

Required:

  • Node.js 18+ — JavaScript runtime (check: node --version)
  • Elixir 1.15+ with OTP 26+ — Elixir runtime (check: elixir --version)
  • PostgreSQL 14+ — Database (check: psql --version)

Optional:

  • tmux — Terminal multiplexer (for make dev convenience)

Installing Dependencies

macOS & Linux (Recommended: asdf)

Why asdf? asdf is a version manager that handles multiple runtimes (Node.js, Elixir, Erlang) from a single tool. It makes it easy to switch between versions and ensures consistent environments across projects.

1. Install asdf

macOS (via Homebrew):

brew install asdf
echo -e "\n. $(brew --prefix asdf)/libexec/asdf.sh" >> ~/.zshrc
source ~/.zshrc

Linux (via git):

git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0
echo '. "$HOME/.asdf/asdf.sh"' >> ~/.bashrc
source ~/.bashrc

2. Install asdf plugins

# Add plugins for each runtime
asdf plugin add nodejs
asdf plugin add elixir
asdf plugin add erlang

3. Install runtimes

# Install specific versions
asdf install nodejs 20.11.0
asdf install erlang 26.2.1
asdf install elixir 1.16.0-otp-26

# Set global defaults
asdf global nodejs 20.11.0
asdf global erlang 26.2.1
asdf global elixir 1.16.0-otp-26

# Verify installations
node --version   # Should show v20.11.0
elixir --version # Should show Elixir 1.16.0

4. Install PostgreSQL

macOS:

brew install postgresql@16
brew services start postgresql@16

# Add to PATH
echo 'export PATH="/opt/homebrew/opt/postgresql@16/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

Linux (Ubuntu/Debian):

sudo apt update
sudo apt install postgresql postgresql-contrib

# Start service
sudo systemctl start postgresql
sudo systemctl enable postgresql

5. Install tmux (optional)

# macOS
brew install tmux

# Linux
sudo apt install tmux

Verify setup:

node --version      # v20.11.0 or higher
elixir --version    # 1.16.0 or higher with OTP 26+
psql --version      # 14.0 or higher

Windows (Recommended: WSL)

Why WSL? Tostada uses a Makefile for development commands, which requires a Unix-like environment. WSL (Windows Subsystem for Linux) provides a full Linux environment on Windows, making it the smoothest development experience.

1. Install WSL

Open PowerShell as Administrator and run:

wsl --install

What this does:

  • Installs WSL 2
  • Installs Ubuntu (default distribution)
  • Sets up a Linux user account

Restart your computer after installation.

2. Launch Ubuntu

Open the “Ubuntu” app from your Start menu. First launch will take a few minutes to set up.

Create a username and password when prompted.

3. Update Ubuntu

sudo apt update && sudo apt upgrade -y

4. Install dependencies (using asdf in WSL)

Follow the Linux instructions above inside your WSL Ubuntu terminal:

# Install asdf
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0
echo '. "$HOME/.asdf/asdf.sh"' >> ~/.bashrc
source ~/.bashrc

# Add plugins
asdf plugin add nodejs
asdf plugin add elixir
asdf plugin add erlang

# Install runtimes
asdf install nodejs 20.11.0
asdf install erlang 26.2.1
asdf install elixir 1.16.0-otp-26

asdf global nodejs 20.11.0
asdf global erlang 26.2.1
asdf global elixir 1.16.0-otp-26

# Install PostgreSQL
sudo apt install postgresql postgresql-contrib
sudo service postgresql start

# Install make (usually pre-installed)
sudo apt install build-essential

# Install tmux
sudo apt install tmux

5. Configure PostgreSQL

# Switch to postgres user and create your user
sudo -u postgres createuser -s $(whoami)
sudo -u postgres createdb $(whoami)

6. Access your Windows files

Your Windows drives are mounted at /mnt/:

  • C:\/mnt/c/
  • D:\/mnt/d/

Create projects in your Linux home directory for best performance:

cd ~
mkdir projects
cd projects

Verify setup:

node --version      # v20.11.0 or higher
elixir --version    # 1.16.0 or higher with OTP 26+
psql --version      # 14.0 or higher
make --version      # GNU Make 4.x

Alternative: Direct Installation (Not Recommended)

If you don’t want to use asdf or WSL, you can install runtimes directly:

macOS (via Homebrew)

brew install node
brew install elixir
brew install postgresql@16

brew services start postgresql@16

Linux (via package manager)

# Node.js (via NodeSource)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install nodejs

# Erlang & Elixir (via Erlang Solutions)
wget https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb
sudo dpkg -i erlang-solutions_2.0_all.deb
sudo apt update
sudo apt install esl-erlang elixir

# PostgreSQL
sudo apt install postgresql postgresql-contrib

Windows (Native - Not Recommended)

While you can install Node.js, Elixir, and PostgreSQL natively on Windows, the Makefile won’t work. You’ll need to run commands manually:

  • Instead of make dev → run mix phx.server and npm run dev in separate terminals
  • Instead of make build → run each build step individually

We strongly recommend using WSL instead.


Installation

Create a new project with the tostada CLI:

npx tostada-cli create MyApp

What happens:

  1. CLI downloads the shared backend template (Phoenix + Makefile + scripts) from GitHub
  2. Prompts you to pick a client variant — SvelteKit, SvelteKit (via sv create), SvelteKit + Threlte, or React + shadcn (see Installers)
  3. Prompts you to select addons available for that variant (Threlte, Model Pipeline, Docker)
  4. Codemods all instances of TostadaMyApp throughout the codebase
  5. Installs dependencies (mix deps.get + npm install)
  6. Creates database and runs migrations

Default variant: sveltekit-threlte (the original SvelteKit + 3D experience). Override with --variant <id>:

npx tostada-cli create MyDashboard --variant sveltekit
npx tostada-cli create MyAdmin     --variant react-shadcn

Default addons (vary by variant):

  • Threlte — 3D scene rendering with Three.js (sveltekit-threlte variant only)
  • Model Pipeline — GLTF asset processing (sveltekit-threlte variant only)
  • Docker — Containerized deployment (offered for every variant)

Skip auto-install:

npx tostada-cli create MyApp --no-install
cd MyApp
make install  # Install deps manually later

Architecture Overview

Tostada is a dual-server architecture: a Vite-based client (variant of your choice) and a headless Phoenix backend working together.

┌─────────────────────────────────────────────────────────────┐
│                        Development                           │
├─────────────────────────────────────────────────────────────┤
│                                                               │
│  Browser (localhost:5173)                                    │
│     │                                                         │
│     └─ ALL requests ──► Vite Dev Server (port 5173)         │
│                         │                                     │
│                         ├─ Serves the SPA with HMR           │
│                         │                                     │
│                         └─ Proxies to Phoenix on :4000:      │
│                            • /api/*    → JSON API            │
│                            • /socket   → WebSocket           │
│                            • /users/*  → (vestigial)         │
│                            • /live     → (vestigial)         │
│                            • /assets/* → (vestigial)         │
│                                                               │
│             Phoenix (port 4000) is headless:                 │
│                         │                                     │
│                         ├─ /api/auth/{register,login,...}    │
│                         ├─ /api/me, /api/socket-token        │
│                         ├─ /socket    WebSocket channels     │
│                         └─ /app/*     SPA shell (prod only)  │
│                                                               │
│             Phoenix renders NO HTML. Auth UI is              │
│             the client's job — see /login + /register        │
│             SPA routes in each variant.                      │
│                                                               │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                        Production                            │
├─────────────────────────────────────────────────────────────┤
│                                                               │
│  Browser (your-domain.com)                                   │
│     │                                                         │
│     └─ All Requests ──► Phoenix Server (port 4000)           │
│                         │                                     │
│                         ├─ /app/* → Static SPA shell         │
│                         │   served from priv/static/app/     │
│                         │                                     │
│                         ├─ /api/* → JSON endpoints           │
│                         └─ /socket → WebSocket               │
│                                                               │
└─────────────────────────────────────────────────────────────┘

Key Differences

AspectDevelopmentProduction
Entry PointVite dev server (localhost:5173)Phoenix server (port 4000)
SPA ServingVite with hot reloadPhoenix serves prebuilt static files
ProxyingVite proxies /api, /socket, etc. to PhoenixNo proxy needed (single server)
File WatchingBoth servers watch their filesNo watching (prebuilt assets)
Build RequiredNo (dev mode)Yes (make build)

Project Structure

After scaffolding, your project has this shape:

my_app/
├── client/      ← varies by variant (sveltekit / sveltekit-threlte / react-shadcn / sveltekit-sv)
├── server/      ← shared Phoenix backend (identical across variants)
├── scripts/     ← shared (build-models.sh, deploy helpers)
├── Makefile
└── README.md

The Makefile lives at the root and orchestrates both halves. Threlte-only models.build / models.clean targets only appear when the model_pipeline addon is enabled.

Important paths to know:

PathWhat it is
client/src/routes/ or client/src/pages/Your SPA pages (varies by variant — SvelteKit file-based vs React routes in main.tsx)
client/src/lib/socket.tsPhoenix WebSocket client + status state. Same shape across variants.
server/lib/<app>_web/router.exRoutes. :api pipeline for JSON, :spa for /app/* shell.
server/lib/<app>_web/channels/WebSocket channels — add new game channel modules here.
server/lib/<app>/Domain logic — GenServers for game state, persistence helpers.
server/lib/<app>_web/controllers/api/JSON API controllers (auth_controller.ex, user_controller.ex).
server/priv/repo/migrations/Database migrations.

See the Project Structure reference for the full annotated tree and the per-variant differences.


Development Mode

In development, you run two servers simultaneously:

  1. Vite Dev Server (port 5173) — Serves SvelteKit SPA with hot reload
  2. Phoenix Server (port 4000) — Handles API, WebSocket, database, auth

Starting Development Servers

Option 1: Use Make with tmux (recommended):

make dev

What happens:

  • Launches tmux with split panes
  • Left pane: Phoenix server (mix phx.server)
  • Right pane: Vite dev server (npm run dev)
  • Both servers log to logs/server.log and logs/client.log

Option 2: Separate terminals (if no tmux):

# Terminal 1
make dev.server

# Terminal 2
make dev.client

The Proxy System (Development)

When you open http://localhost:5173 in your browser, you’re accessing the Vite dev server.

Vite configuration (client/vite.config.ts):

export default defineConfig({
  server: {
    port: 5173,
    proxy: {
      '/api': 'http://localhost:4000',      // JSON API + auth endpoints
      '/socket': {                           // WebSocket
        target: 'http://localhost:4000',
        ws: true
      },
      '/users': 'http://localhost:4000',     // vestigial — Phoenix no longer serves here
      '/live': 'http://localhost:4000',      // vestigial
      '/assets': 'http://localhost:4000'     // vestigial
    }
  }
});

How it works:

  1. Browser requests /app/game → Vite serves the SPA (instant HMR)
  2. Browser requests /api/me → Vite proxies to Phoenix at localhost:4000/api/me
  3. Browser connects to /socket → Vite upgrades to WebSocket, proxies to Phoenix
  4. Auth pages (/login, /register) → handled by the SPA itself, NOT Phoenix. The forms POST to /api/auth/login and /api/auth/register which DO proxy to Phoenix.

Result: You develop against localhost:5173, but all backend requests transparently go to Phoenix. Cookies, sessions, and WebSockets work seamlessly.

Request Flow Example

User opens http://localhost:5173/app
    ↓
Vite Dev Server serves SvelteKit SPA (index.html + JS bundle)
    ↓
SPA JavaScript executes, calls fetch('/api/me')
    ↓
Vite proxy forwards to http://localhost:4000/api/me
    ↓
Phoenix handles request, returns JSON
    ↓
Response flows back through proxy to browser
    ↓
SPA updates UI with user data

Hot Module Replacement (HMR)

When you edit files:

  • Svelte files (client/src/**/*.svelte) → Vite HMR (instant update, no page reload)
  • TypeScript files (client/src/**/*.ts) → Vite HMR (instant update)
  • CSS files (client/src/**/*.css) → Vite HMR (instant update)
  • Elixir files (server/lib/**/*.ex) → Phoenix recompiles (automatic reload)

No manual restart needed — both servers watch their files and update automatically.


Production Mode

In production, only Phoenix runs. SvelteKit is prebuilt into static assets that Phoenix serves.

Build Process

make build

What happens (step by step):

  1. Compile Phoenix (MIX_ENV=prod mix compile)

    • Compiles Elixir code to BEAM bytecode
    • Output: server/_build/prod/
  2. Run client prebuild (npm run prebuild in client/)

    • Wipes server/priv/static/app/
    • Prepares for production build
  3. Build the client (npm run build in client/)

    • SvelteKit variants: vite build via the adapter-static, outputs to ../server/priv/static/app/
    • React variant: tsc -b && vite build writes the same output dir
    • Tree-shaking, minification, hashed asset names

Phoenix has no asset pipeline of its own. The server is JSON + WebSocket only — there’s no tailwind/esbuild step on the server side. The only “assets” Phoenix serves are the client’s already-built output.

Final structure:

server/priv/static/
└── app/                  # Client build output (step 3)
    ├── _app/             # JS chunks, CSS
    ├── index.html        # SPA shell
    └── ...               # Other emitted assets

Serving Static Assets

Phoenix configuration (server/lib/my_app_web/router.ex):

scope "/app", MyAppWeb do
  pipe_through :browser
  get "/*path", SPAController, :index
end

SPAController (server/lib/my_app_web/controllers/spa_controller.ex):

def index(conn, _params) do
  conn
  |> put_resp_header("content-type", "text/html; charset=utf-8")
  |> send_file(200, Application.app_dir(:my_app, "priv/static/app/index.html"))
end

How it works:

  1. User requests /app/game
  2. Phoenix matches route "/app/*path"SPAController.index/2
  3. Controller serves priv/static/app/index.html (SvelteKit shell)
  4. Browser loads HTML, fetches JS/CSS from /app/_app/ (also served by Phoenix)
  5. SvelteKit SPA hydrates, takes over client-side routing

All routes under /app/* serve the same index.html — SvelteKit handles routing on the client side.

Request Flow (Production)

User opens https://your-game.com/app
    ↓
Phoenix matches /app/* route
    ↓
SPAController serves priv/static/app/index.html
    ↓
Browser parses HTML, requests JS/CSS from /app/_app/*
    ↓
Phoenix serves static files from priv/static/app/
    ↓
SvelteKit JavaScript executes, takes over routing
    ↓
SPA calls fetch('/api/me')
    ↓
Phoenix API endpoint handles request (no proxy needed)
    ↓
Response returned directly to browser

No separate frontend server — Phoenix serves everything (static SPA + API + WebSocket).


Build System Deep Dive

SvelteKit Adapter Configuration

File: client/svelte.config.js

import adapter from '@sveltejs/adapter-static';

export default {
  kit: {
    adapter: adapter({
      fallback: '404.html'  // SPA fallback for client-side routing
    }),
    paths: {
      base: process.env.BASE_PATH || ''  // Empty for /app/* routing
    }
  }
};

Key points:

  • adapter-static builds a static site (not SSR)
  • Output directory: ../server/priv/static/app/ (configured in svelte.config.js)
  • fallback: '404.html' enables client-side routing (all unknown routes serve index.html)

No Server-Side Asset Pipeline

Phoenix in this stack is headless — there’s no tailwind, esbuild, or HEEX layouts on the server. The auth UI lives in the client (/login and /register SPA routes); the admin UI, if you build one, lives in the client too.

The only server-side build output is the migrated database schema and the compiled BEAM bytecode (server/_build/prod/). All visual / interactive assets come from the Vite client build dropped into server/priv/static/app/.

Digested Assets (Cache Busting)

Vite handles cache busting automatically — every build emits hashed asset filenames:

Before:  index.js
After:   index-a1b2c3d4.js

These hashes are referenced from the built index.html so the browser fetches new bundles after each deploy without manual cache invalidation.


Common Workflows

First-Time Setup

# 1. Create project
npx tostada-cli create MyGame

# 2. Start development
cd MyGame
make dev

# 3. Open browser
open http://localhost:5173

Daily Development

# Start both servers (leave running)
make dev

# Edit code in your editor:
# - client/src/routes/  (SvelteKit pages) or client/src/pages/ (React pages)
# - server/lib/my_app_web/channels/  (WebSocket channels)
# - server/lib/my_app/                (game server logic)

# Browser auto-reloads on changes

Testing Before Deployment

# Run tests
make test

# Build production assets locally
make build

# Test production build (optional)
cd server
MIX_ENV=prod mix phx.server
# Visit http://localhost:4000/app

Deploying to Production

# On your server
make deploy.build   # Build release tarball
make deploy.release # Run migrations + restart app

# Or full pipeline
make deploy.full    # git pull + build + deploy

Addon Modifiers

Disable specific addons during project creation:

# Disable Threlte and Docker
npx tostada-cli create MyApp -threlte,-docker

# Enable only Docker (disable default addons)
npx tostada-cli create MyApp docker,-threlte,-model_pipeline

Effect: The CLI removes:

  • Dependencies from package.json / mix.exs
  • Configuration files
  • Boilerplate code (e.g., Scene.svelte if -threlte)
  • Makefile targets (e.g., models.build if -model_pipeline)

Troubleshooting

“mix: command not found”

Cause: Elixir not installed.

Solution: Install Elixir (https://elixir-lang.org/install.html)


“npm: command not found”

Cause: Node.js not installed.

Solution: Install Node.js (https://nodejs.org)


Vite Dev Server Won’t Start

Cause: Port 5173 already in use.

Solution:

# Find process using port
lsof -ti:5173 | xargs kill -9

# Or change port in client/vite.config.ts
server: { port: 5174 }

Phoenix Server Won’t Start

Cause: Port 4000 already in use or database not running.

Solutions:

# Check if PostgreSQL is running
pg_ctl status

# Start PostgreSQL (macOS)
brew services start postgresql

# Kill process on port 4000
lsof -ti:4000 | xargs kill -9

Assets Not Loading in Production

Cause: Build step skipped or priv/static/app/ doesn’t exist.

Solution:

make build
ls server/priv/static/app/  # Should see index.html

Next Steps

Now that you understand how tostada works:


Ready to build something? The Putting It Together guide walks through building a complete multiplayer game from scratch.