Project Structure
An annotated guide to a Tostada project’s layout — what gets generated, what’s shared, and what varies by client variant.
The Two Repos
Tostada is split across two GitHub repos plus an installer CLI:
| Repo | What it contains |
|---|---|
gamedev-company/tostada | The shared backend — Phoenix server, Makefile, deploy scripts. Headless JSON + WebSocket API, no HTML rendering, no client code. |
gamedev-company/tostada-cli | The npm-published installer. Ships four client templates and scaffolds projects by combining the boilerplate (above) with a chosen client. |
When you run npx tostada-cli create MyApp --variant <id>, the CLI:
- Fetches the
tostadarepo tarball (server + Makefile + scripts) - Copies its bundled
templates/clients/<variant>/intoclient/ - Runs any subprocess scaffolders (
sv createorshadcn add) - Codemods every
tostada→my_appreference - Installs deps
What a Generated Project Looks Like
After npx tostada-cli create MyApp --variant sveltekit-threlte, you get:
my_app/
├── client/ ← variant-specific (one of four)
├── server/ ← shared Phoenix backend (identical across variants)
├── scripts/ ← shared (build-models.sh, deploy helpers)
├── Makefile ← shared, with conditional Threlte-only targets appended
└── README.md The only thing that differs between variants is client/. Everything else is identical.
Server (shared across all variants)
The server/ directory is a stock Phoenix 1.8 app with all HTML/LiveView surface area removed.
server/
├── lib/
│ ├── tostada/
│ │ ├── application.ex # Supervision tree — add your GameServer here
│ │ ├── repo.ex # Ecto repo
│ │ ├── mailer.ex # Swoosh (dev: local mailbox at /dev/mailbox)
│ │ ├── release.ex # Production release tasks
│ │ └── accounts/
│ │ ├── user.ex
│ │ ├── user_token.ex
│ │ ├── user_notifier.ex
│ │ └── scope.ex
│ ├── tostada_web/
│ │ ├── endpoint.ex # /socket WebSocket; static SPA serving at /app
│ │ ├── router.ex # :api + :spa pipelines only
│ │ ├── telemetry.ex
│ │ ├── presence.ex
│ │ ├── user_auth.ex # fetch_current_scope_for_user + log_in/out helpers
│ │ ├── channels/
│ │ │ ├── user_socket.ex # dual auth: Phoenix.Token or session cookie
│ │ │ └── app_channel.ex # "app:lobby" — extend or replace
│ │ ├── controllers/
│ │ │ ├── spa_controller.ex
│ │ │ ├── error_json.ex
│ │ │ └── api/
│ │ │ ├── auth_controller.ex # register / login / logout / forgot+reset password
│ │ │ └── user_controller.ex # /api/me, /api/socket-token
│ │ └── plugs/
│ │ ├── cors.ex # dev only
│ │ ├── require_admin.ex
│ │ └── static_assets.ex
│ └── tostada_web.ex # JSON-only (no :html, :live_view, :live_component)
├── priv/
│ ├── static/ # SPA build output lands here
│ │ ├── app/ # SvelteKit/Vite build → served at /app
│ │ ├── images/
│ │ └── robots.txt
│ └── repo/
│ ├── migrations/ # users + users_tokens schema
│ └── seeds.exs # admin seed (set SEED_ADMIN_EMAIL/PASSWORD)
├── test/
│ ├── support/
│ ├── tostada/
│ └── tostada_web/
├── config/
│ ├── config.exs
│ ├── dev.exs
│ ├── prod.exs
│ ├── runtime.exs
│ └── test.exs
├── scripts/deploy/
│ ├── build.sh
│ ├── deploy.sh
│ ├── full-deploy.sh
│ ├── nginx.conf
│ └── systemd.service
├── mix.exs
└── mix.lock What’s not there (intentionally)
- No
assets/directory — Phoenix’s tailwind/esbuild pipeline is dropped - No
lib/tostada_web/live/— no LiveView UI - No
lib/tostada_web/components/— no HTML layouts or CoreComponents - No
priv/gettext/— no i18n on the server (clients handle their own) - No
/users/*HTML routes — auth UI is the client’s responsibility, server exposes JSON only
The Phoenix backend’s job is: handle JSON auth, run channels, serve the SPA shell, persist data. Nothing else.
Key files to know
| File | What it does |
|---|---|
lib/tostada/application.ex | Add a Tostada.GameServer (or whatever you build) to the supervision tree here. |
lib/tostada_web/router.ex | pipeline :api runs fetch_current_scope_for_user. New JSON endpoints go in /api/* scopes. |
lib/tostada_web/channels/user_socket.ex | Add new channel topics here (channel "game:*", Tostada.GameChannel). |
lib/tostada_web/channels/app_channel.ex | Reference channel — handles join, ping. Copy this when starting new game channels. |
lib/tostada_web/user_auth.ex | fetch_current_scope_for_user/2 is the plug that loads :current_scope. JSON controllers branch on nil vs %Scope{}. |
priv/repo/migrations/* | users (id, email, hashed_password, display_name, is_admin) + users_tokens (token, context, user_id, sent_to, authenticated_at). |
Client (varies by variant)
Each variant produces a different client/ directory. The high-level shape is similar — Vite-based dev server, proxy to Phoenix on :4000, build to ../server/priv/static/app/ — but the framework, routes, and dependencies differ.
sveltekit and sveltekit-threlte
client/
├── src/
│ ├── app.html # SvelteKit entry
│ ├── app.css # Space Grotesk + dark theme
│ ├── app.d.ts
│ ├── phoenix.d.ts # `phoenix` npm package types
│ ├── vite-env.d.ts
│ ├── lib/
│ │ ├── socket.ts # Phoenix client + writable stores
│ │ └── index.ts
│ └── routes/
│ ├── +layout.svelte
│ ├── +page.svelte # landing (auth-nav buttons → /login, /register)
│ ├── login/+page.svelte
│ ├── register/+page.svelte
│ └── Scene.svelte # Threlte variant only
├── static/
│ ├── favicon.svg
│ └── robots.txt
├── svelte.config.js # adapter-static, base: '/app' in prod
├── vite.config.ts # proxy /api, /socket, /users, /live, /assets → :4000
├── vitest.config.ts
├── tsconfig.json
├── package.json
└── .npmrc # engine-strict=true sveltekit-threlte adds @threlte/core, @threlte/extras, three, and @types/three to dependencies, and includes Scene.svelte rendering the default 3D world via <Canvas>.
react-shadcn
client/
├── src/
│ ├── main.tsx # React 19 entry, BrowserRouter setup
│ ├── App.tsx # landing (shadcn Button with /login, /register links)
│ ├── index.css # Tailwind directives + shadcn theme variables
│ ├── vite-env.d.ts
│ ├── lib/
│ │ ├── socket.ts # Phoenix client (framework-agnostic, subscribe-based)
│ │ └── utils.ts # shadcn cn() helper
│ ├── pages/
│ │ ├── Login.tsx
│ │ └── Register.tsx
│ └── components/ui/ # shadcn components (added by `shadcn add`)
│ ├── button.tsx
│ ├── card.tsx
│ ├── input.tsx
│ ├── form.tsx
│ ├── dialog.tsx
│ └── label.tsx
├── public/
│ └── favicon.svg
├── index.html
├── vite.config.ts # @vitejs/plugin-react + same proxy block
├── tsconfig.json
├── tsconfig.app.json
├── tsconfig.node.json
├── tailwind.config.js
├── postcss.config.js
├── components.json # shadcn config (pre-committed)
└── package.json sveltekit-sv
This variant doesn’t ship a full client tree — instead the CLI runs npx sv@latest create interactively and then overlays a small set of Phoenix-wiring files:
vite.config.ts(proxy block)svelte.config.js(adapter-static + base path)src/lib/socket.tssrc/phoenix.d.ts
Plus it patches package.json to add @sveltejs/adapter-static and phoenix. The rest of the client tree is whatever sv create generated based on your template and adder choices.
See the Installers overview for a side-by-side comparison.
Scripts
scripts/
├── build-models.sh # GLTF/GLB → Svelte component pipeline (Threlte variant)
└── ... The model pipeline only matters for the sveltekit-threlte variant — the corresponding make models.build Makefile target is only appended when the model_pipeline addon is enabled at install time.
Makefile
The shared Makefile orchestrates both halves. Targets are framework-agnostic — they delegate to npm run <script> in client/, so each variant’s package.json decides what check, build, test mean.
| Target | What it does |
|---|---|
make install | mix deps.get + npm install |
make dev | tmux pane for mix phx.server + another for npm run dev |
make dev.server / make dev.client | Run either half on its own |
make db.setup / make db.reset / make db.migrate | Database lifecycle |
make test | mix test + npm run test |
make build | Production build (client → server/priv/static/app) |
make deploy.{build,release,full} | Production release tooling |
make clean | Remove build artifacts (covers SvelteKit + React) |
Threlte variant also gets:
| Target | What it does |
|---|---|
make models.build | Run scripts/build-models.sh |
make models.clean | Wipe generated model components |
See the Makefile Reference for the full target list.
Config
Root
| File | Notes |
|---|---|
Makefile | Generic shape; Threlte adds models.* targets via CLI append |
.gitignore | Covers Elixir + Node + framework outputs |
README.md | Project-specific; edit freely after scaffolding |
Client
Varies by variant. The proxy block (which paths route to Phoenix at :4000 in dev) is identical across all four — only the framework plugin changes.
Server
| File | Notes |
|---|---|
mix.exs | Stripped of :phoenix_html, :phoenix_live_view, :esbuild, :tailwind, :heroicons, :gettext. |
config/dev.exs | dev_routes: true enables the /dev/mailbox Swoosh preview |
config/runtime.exs | Where production secrets get read (DATABASE_URL, SECRET_KEY_BASE) |
Build outputs (gitignored)
Client (varies by variant)
- SvelteKit variants:
client/.svelte-kit/,client/build/ - React:
client/dist/ - All:
client/node_modules/
Server
server/_build/— compiled BEAM bytecodeserver/deps/— fetched Hex packagesserver/priv/static/app/— built SPA shell (deployed)
Common workflows
Add a server-side feature with a WebSocket channel
- Create
lib/tostada/game_server.ex(a GenServer) and add it toapplication.ex - Create
lib/tostada_web/channels/game_channel.ex - Register the topic in
user_socket.ex(channel "game:*", Tostada.GameChannel) - On the client, use
socket.channel("game:room-1")fromsrc/lib/socket.ts
Add a JSON endpoint
- Add a function to a controller under
lib/tostada_web/controllers/api/ - Wire the route into
router.exunder the:apipipeline - Read
conn.assigns[:current_scope]to get the authenticated user (ornil)
Add a database table
cd server && mix ecto.gen.migration create_games- Edit the migration file in
priv/repo/migrations/ make db.migrate- Create the schema module under
lib/tostada/games/
Add a SPA route on the client
- SvelteKit: drop
src/routes/<path>/+page.svelte - React: add
<Route path="/<path>" element={<Component />} />inmain.tsx
Next steps
- Makefile Reference — all the
maketargets - Auth System — JSON auth endpoints + cookie + socket-token
- Channels + Stores — wiring channels to client state
- Installers — variant selection + what each one includes