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:

RepoWhat it contains
gamedev-company/tostadaThe shared backend — Phoenix server, Makefile, deploy scripts. Headless JSON + WebSocket API, no HTML rendering, no client code.
gamedev-company/tostada-cliThe 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:

  1. Fetches the tostada repo tarball (server + Makefile + scripts)
  2. Copies its bundled templates/clients/<variant>/ into client/
  3. Runs any subprocess scaffolders (sv create or shadcn add)
  4. Codemods every tostadamy_app reference
  5. 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

FileWhat it does
lib/tostada/application.exAdd a Tostada.GameServer (or whatever you build) to the supervision tree here.
lib/tostada_web/router.expipeline :api runs fetch_current_scope_for_user. New JSON endpoints go in /api/* scopes.
lib/tostada_web/channels/user_socket.exAdd new channel topics here (channel "game:*", Tostada.GameChannel).
lib/tostada_web/channels/app_channel.exReference channel — handles join, ping. Copy this when starting new game channels.
lib/tostada_web/user_auth.exfetch_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.ts
  • src/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.

TargetWhat it does
make installmix deps.get + npm install
make devtmux pane for mix phx.server + another for npm run dev
make dev.server / make dev.clientRun either half on its own
make db.setup / make db.reset / make db.migrateDatabase lifecycle
make testmix test + npm run test
make buildProduction build (client → server/priv/static/app)
make deploy.{build,release,full}Production release tooling
make cleanRemove build artifacts (covers SvelteKit + React)

Threlte variant also gets:

TargetWhat it does
make models.buildRun scripts/build-models.sh
make models.cleanWipe generated model components

See the Makefile Reference for the full target list.


Config

Root

FileNotes
MakefileGeneric shape; Threlte adds models.* targets via CLI append
.gitignoreCovers Elixir + Node + framework outputs
README.mdProject-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

FileNotes
mix.exsStripped of :phoenix_html, :phoenix_live_view, :esbuild, :tailwind, :heroicons, :gettext.
config/dev.exsdev_routes: true enables the /dev/mailbox Swoosh preview
config/runtime.exsWhere 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 bytecode
  • server/deps/ — fetched Hex packages
  • server/priv/static/app/ — built SPA shell (deployed)

Common workflows

Add a server-side feature with a WebSocket channel

  1. Create lib/tostada/game_server.ex (a GenServer) and add it to application.ex
  2. Create lib/tostada_web/channels/game_channel.ex
  3. Register the topic in user_socket.ex (channel "game:*", Tostada.GameChannel)
  4. On the client, use socket.channel("game:room-1") from src/lib/socket.ts

Add a JSON endpoint

  1. Add a function to a controller under lib/tostada_web/controllers/api/
  2. Wire the route into router.ex under the :api pipeline
  3. Read conn.assigns[:current_scope] to get the authenticated user (or nil)

Add a database table

  1. cd server && mix ecto.gen.migration create_games
  2. Edit the migration file in priv/repo/migrations/
  3. make db.migrate
  4. Create the schema module under lib/tostada/games/

Add a SPA route on the client

  1. SvelteKit: drop src/routes/<path>/+page.svelte
  2. React: add <Route path="/<path>" element={<Component />} /> in main.tsx

Next steps