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 template from GitHub
  2. Prompts you to select addons (Threlte, Model Pipeline, Docker)
  3. Codemods all instances of TostadaMyApp throughout the codebase
  4. Installs dependencies (mix deps.get + npm install)
  5. 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

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 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:

  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',      // 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:

  1. Browser requests /app/game → Vite serves SvelteKit page (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. 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):

  1. Compile Phoenix (MIX_ENV=prod mix compile)

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

    • Cleanup tasks, linting, type checking
    • Prepares for production build
  3. Build SvelteKit static site (npm run build in client/)

    • 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
  4. 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:

  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)

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.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.