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 devconvenience)
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→ runmix phx.serverandnpm run devin 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:
- CLI downloads the shared backend template (Phoenix + Makefile + scripts) from GitHub
- Prompts you to pick a client variant — SvelteKit, SvelteKit (via
sv create), SvelteKit + Threlte, or React + shadcn (see Installers) - Prompts you to select addons available for that variant (Threlte, Model Pipeline, Docker)
- Codemods all instances of
Tostada→MyAppthroughout the codebase - Installs dependencies (
mix deps.get+npm install) - 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-threltevariant only) - ✅ Model Pipeline — GLTF asset processing (
sveltekit-threltevariant 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
| Aspect | Development | Production |
|---|---|---|
| Entry Point | Vite dev server (localhost:5173) | Phoenix server (port 4000) |
| SPA Serving | Vite with hot reload | Phoenix serves prebuilt static files |
| Proxying | Vite proxies /api, /socket, etc. to Phoenix | No proxy needed (single server) |
| File Watching | Both servers watch their files | No watching (prebuilt assets) |
| Build Required | No (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:
| Path | What 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.ts | Phoenix WebSocket client + status state. Same shape across variants. |
server/lib/<app>_web/router.ex | Routes. :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:
- Vite Dev Server (port 5173) — Serves SvelteKit SPA with hot reload
- 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.logandlogs/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:
- Browser requests
/app/game→ Vite serves the SPA (instant HMR) - Browser requests
/api/me→ Vite proxies to Phoenix atlocalhost:4000/api/me - Browser connects to
/socket→ Vite upgrades to WebSocket, proxies to Phoenix - Auth pages (
/login,/register) → handled by the SPA itself, NOT Phoenix. The forms POST to/api/auth/loginand/api/auth/registerwhich 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):
Compile Phoenix (
MIX_ENV=prod mix compile)- Compiles Elixir code to BEAM bytecode
- Output:
server/_build/prod/
Run client prebuild (
npm run prebuildinclient/)- Wipes
server/priv/static/app/ - Prepares for production build
- Wipes
Build the client (
npm run buildinclient/)- SvelteKit variants:
vite buildvia the adapter-static, outputs to../server/priv/static/app/ - React variant:
tsc -b && vite buildwrites the same output dir - Tree-shaking, minification, hashed asset names
- SvelteKit variants:
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:
- User requests
/app/game - Phoenix matches route
"/app/*path"→SPAController.index/2 - Controller serves
priv/static/app/index.html(SvelteKit shell) - Browser loads HTML, fetches JS/CSS from
/app/_app/(also served by Phoenix) - 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-staticbuilds 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.svelteif-threlte) - Makefile targets (e.g.,
models.buildif-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:
- Build a game: Putting It Together Guide
- Add multiplayer: Channels + Stores Guide
- Learn the structure: Project Structure Reference
- Master the commands: Makefile Reference
Ready to build something? The Putting It Together guide walks through building a complete multiplayer game from scratch.