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 template from GitHub
- Prompts you to select addons (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 addons:
- ✅ Threlte — 3D scene rendering with Three.js
- ✅ Model Pipeline — GLTF asset processing
- ✅ Docker — Containerized deployment (optional)
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: SvelteKit (frontend) + Phoenix (backend) working together.
┌─────────────────────────────────────────────────────────────┐
│ Development │
├─────────────────────────────────────────────────────────────┤
│ │
│ Browser (localhost:5173) │
│ │ │
│ ├─ /app/* ────────► Vite Dev Server (port 5173) │
│ │ │ │
│ │ ├─ Serves SvelteKit SPA (HMR) │
│ │ │ │
│ │ └─ Proxies to Phoenix: │
│ │ • /api/* → localhost:4000 │
│ │ • /socket → localhost:4000 │
│ │ • /live → localhost:4000 │
│ │ • /users/* → localhost:4000 │
│ │ • /assets/* → localhost:4000 │
│ │ │
│ └─ /api/*, /socket ─► Phoenix Server (port 4000) │
│ │ │
│ ├─ REST API endpoints │
│ ├─ WebSocket (channels) │
│ ├─ LiveView (admin) │
│ └─ Auth pages (/users/*) │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Production │
├─────────────────────────────────────────────────────────────┤
│ │
│ Browser (your-domain.com) │
│ │ │
│ └─ All Requests ──► Phoenix Server (port 4000) │
│ │ │
│ ├─ /app/* → Static SPA (built) │
│ │ └─ priv/static/app/ │
│ │ │
│ ├─ /api/* → REST endpoints │
│ ├─ /socket → WebSocket │
│ ├─ /live → LiveView │
│ └─ /users/* → Auth pages │
│ │
└─────────────────────────────────────────────────────────────┘ 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 structure:
my_app/
├── client/ # SvelteKit frontend (Vite + Svelte 5 + Threlte)
│ ├── src/
│ │ ├── routes/ # SvelteKit pages (file-based routing)
│ │ │ ├── +layout.svelte # App shell (Canvas + Sidebar)
│ │ │ ├── +page.svelte # Homepage (full-screen 3D scene)
│ │ │ └── Scene.svelte # Default Threlte scene (animated box)
│ │ ├── lib/
│ │ │ ├── socket.ts # Phoenix channel connection + stores
│ │ │ ├── components/ # Reusable Svelte components
│ │ │ └── models/ # 3D model components (generated)
│ │ ├── app.css # Tailwind imports + global styles
│ │ └── app.html # HTML template (SvelteKit entry)
│ ├── static/ # Static assets (served as-is)
│ │ └── models/ # Optimized GLB files (generated)
│ ├── svelte.config.js # SvelteKit config (builds to ../server/priv/static/app)
│ ├── vite.config.ts # Vite config (dev server + proxy setup)
│ └── package.json # npm dependencies (SvelteKit, Threlte, Three.js)
│
├── server/ # Phoenix backend (Elixir + Ecto + PostgreSQL)
│ ├── lib/
│ │ ├── my_app/ # Domain logic (business rules, game servers)
│ │ │ ├── application.ex # Supervision tree (starts all processes)
│ │ │ ├── accounts/ # User authentication domain
│ │ │ └── repo.ex # Ecto repository (database interface)
│ │ └── my_app_web/ # Web layer (controllers, channels, LiveView)
│ │ ├── router.ex # URL routing (/, /api/*, /app/*, /users/*)
│ │ ├── endpoint.ex # HTTP/WebSocket entry point
│ │ ├── channels/ # WebSocket channels (real-time communication)
│ │ │ ├── user_socket.ex # Socket auth (token + session)
│ │ │ └── app_channel.ex # Default channel (ping, whoami)
│ │ ├── controllers/ # HTTP request handlers
│ │ │ ├── spa_controller.ex # Serves /app/* SPA routes
│ │ │ └── api/ # REST API endpoints
│ │ └── presence.ex # Phoenix.Presence (player tracking)
│ ├── priv/
│ │ ├── static/ # Static files served by Phoenix
│ │ │ ├── app/ # SvelteKit build output (generated)
│ │ │ ├── assets/ # CSS/JS for auth pages (Tailwind + esbuild)
│ │ │ └── obj/ # GLTF/GLB source files (optional)
│ │ └── repo/
│ │ ├── migrations/ # Database schema changes
│ │ └── seeds.exs # Sample data
│ ├── config/ # Phoenix configuration
│ │ ├── dev.exs # Development settings (ports, database)
│ │ ├── prod.exs # Production settings
│ │ └── runtime.exs # Runtime environment variables
│ └── mix.exs # Elixir project definition + dependencies
│
├── scripts/
│ ├── build-models.sh # GLTF → Svelte component pipeline
│ └── init.sh # First-time setup script
│
├── Makefile # Development commands (dev, test, build, deploy)
└── README.md # Project documentation Important paths:
client/src/routes/— Your SvelteKit pages (where you build the game UI)client/src/routes/Scene.svelte— Default 3D scene (extend this for your game)server/lib/my_app_web/channels/— WebSocket channels (add game channels here)server/lib/my_app/— Game server logic (add GenServers here)
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', // REST API
'/socket': { // WebSocket
target: 'http://localhost:4000',
ws: true
},
'/live': 'http://localhost:4000', // LiveView
'/users': 'http://localhost:4000', // Auth pages
'/assets': 'http://localhost:4000' // Phoenix static assets
}
}
}); How it works:
- Browser requests
/app/game→ Vite serves SvelteKit page (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 - Browser requests
/users/login→ Vite proxies to Phoenix (server-rendered page)
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 SvelteKit prebuild (
npm run prebuildinclient/)- Cleanup tasks, linting, type checking
- Prepares for production build
Build SvelteKit static site (
npm run buildinclient/)- SvelteKit adapter-static builds to
../server/priv/static/app/ - Output: Prerendered HTML, JS chunks, CSS, assets
- Tree-shaking removes unused code
- Minification for smaller file sizes
- SvelteKit adapter-static builds to
Deploy Phoenix assets (
MIX_ENV=prod mix assets.deploy)- Compiles Tailwind CSS for auth pages
- Bundles JavaScript with esbuild
- Generates digested filenames (e.g.,
app-ABCD1234.js) - Output:
server/priv/static/assets/
Final structure:
server/priv/static/
├── app/ # SvelteKit build (step 3)
│ ├── _app/ # JS chunks, CSS
│ ├── index.html # Prerendered SPA shell
│ └── ... # Other prerendered pages
└── assets/ # Phoenix assets (step 4)
├── app-ABCD1234.js # Auth pages JavaScript
└── app-ABCD1234.css # Auth pages CSS 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)
Asset Pipeline (Phoenix)
Phoenix still needs to build assets for non-SPA pages (auth pages, LiveView admin):
Configured in: server/config/config.exs
config :my_app, MyAppWeb.Endpoint,
assets: [
tailwind: {Tailwind, :install_and_run, [:default, ~w(--minify)]},
esbuild: {Esbuild, :install_and_run, [:default, ~w(--minify)]}
] What it builds:
- Auth pages:
/users/login,/users/register - LiveView admin:
/admin - Custom Phoenix templates (if any)
Output: server/priv/static/assets/
Digested Assets (Cache Busting)
In production, Phoenix generates digested filenames:
Before: app.js
After: app-a1b2c3d4.js Manifest (server/priv/static/cache_manifest.json):
{
"version": 1,
"latest": {
"app.js": "app-a1b2c3d4.js",
"app.css": "app-e5f6g7h8.css"
}
} Why: Ensures browsers fetch new assets after deployments (cache busting).
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/Scene.svelte (3D scene)
# - server/lib/my_app_web/channels/game_channel.ex (WebSocket)
# 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.