# Deploy contract {#deploy} This page is the full reference for deploying a web game or app on gbandit. For a guided path, see [use the template](/getting-started-template) or [deploy an existing app](/getting-started-existing). The short version: 1. Add a `gbandit.jsonc` at the repo root declaring your components. 2. A `backend` component's container must listen on `$PORT` (8080). 3. A `frontend` component's image must export a static site. 4. Run `gbandit deploy --create`. 5. Once it works in dev, run `gbandit promote`. 6. To list it on games.gbandit.com, upload a cover with `gbandit project cover ` and run `gbandit project publish`. Every project gets two environments, `dev` and `prod`, at `https://dev-.gbandit.com` and `https://.gbandit.com`. `gbandit deploy` builds your project and rolls it out to dev; it takes no environment, because dev is the only one a build can reach. `gbandit promote` points prod at the release dev is running and rolls it out. Nothing is built, so prod serves byte for byte the images you verified in dev. A snapshot of prod's database is taken before the rollout. To roll back, deploy the earlier code to dev and promote it. **Promote to prod** on the project's Deployments page does the same from the dashboard, and shows which commit prod runs and which it moves to. ## gbandit.jsonc {#gbandit-jsonc} Project configuration at the repo root. The file is JSONC: comments and trailing commas are allowed. ```jsonc { "project": "my-game", "title": "My Game", "frontend": { "dockerfile": "frontend/Dockerfile", "context": "frontend" }, // Remove or comment out "backend" for a static-only project. "backend": { "dockerfile": "backend/Dockerfile", "context": "backend", "volume": { "sqlite": true } }, "local_dev": { "auto_commit": false, }, } ``` | Field | Required | Meaning | | ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `project` | yes | Project slug, which becomes the subdomain: 1 to 55 lowercase letters, digits and hyphens, starting and ending with a letter or digit, and not starting with `dev-`, `stage-` or `prod-`. | | `title` | no | Display title. When present, deploy keeps the platform title in sync. Omit to manage the title in the web UI. | | `frontend` | no\* | Static frontend component: `{ "dockerfile": , "context": }`. See [Components](#components). | | `backend` | no\* | Backend component: `{ "dockerfile": , "context": }`, optionally with `"volume": { "sqlite": true }` for a SQLite database on its own disk. See [Components](#components) and [Databases](#database). | | `database` | no | `{ "postgres": true }` for a managed Postgres database. Requires a `backend`, and cannot be combined with a `volume`. Omit the section entirely for no managed database. Removing it removes a provisioned database; see [Databases](#database). | | `local_dev.auto_commit` | no | Default `false`: deploy never touches git. See [Git behaviour](#git). | \* At least one of `frontend` and `backend` must be declared. **Unknown fields are rejected**, so a misspelled field fails the deploy instead of being ignored. The config is validated when you run `gbandit deploy`, before anything is built. ## Components {#components} The `frontend` and `backend` objects declare what the project consists of. **Presence is the switch**: a declared component is built and deployed; an undeclared one is not. Deploying with a component removed also removes it from the environment. Each component has two required fields, both paths relative to the repo root: - **`dockerfile`**: the Dockerfile to build. - **`context`**: the docker build context directory. `COPY` instructions resolve inside it. Use the component's own directory for a conventional layout, or `"."` (the repo root) when backend and frontend share code the Dockerfiles need to `COPY`. The dockerfile does not have to live inside the context. If `backend` or `frontend` isn't declared but `backend/Dockerfile` or `frontend/Dockerfile` exists, the deploy output warns that it was skipped. ## Backend Dockerfile {#dockerfile} The backend component's Dockerfile is built as it is, so **any language works**. The resulting container must: - **Listen on `$PORT`** (injected, currently `8080`). Hardcoding 8080 also works, but reading `PORT` is the portable choice. - **Only expect `DATABASE_URL` if a database is declared.** The variable is set exactly when `gbandit.jsonc` declares a `volume` on the backend or a `database` (see [Databases](#database)); otherwise it is unset. - **Survive the runtime hardening** below. Most importantly, the process runs as UID 65534 with a read-only root filesystem, so it must not write outside `/tmp`. Readiness is a TCP probe on the listen port: the app is "up" as soon as it accepts connections. ## Static frontend {#static-frontend} The frontend component's Dockerfile must produce an image containing the **built static site**, with `index.html` at the image root, `/out`, or `/app/dist`. Any bundler works. Example: ```dockerfile FROM oven/bun:1-alpine AS builder WORKDIR /app COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . RUN bun run build FROM scratch COPY --from=builder /app/dist / ``` The files are published to the platform's release store and served by the platform's static origin. The frontend image is never run as a container. Content types are derived from file extensions (any file type works). The static site keeps being served while the backend hibernates. Extensionless routes fall back to `index.html` (SPA routing works). Your `index.html` should load the [gbandit runtime](/runtime) with `` in ``. The platform never adds it for you. A deploy whose `index.html` doesn't load it succeeds but warns, because without it players aren't counted, browser errors don't reach `gbandit logs frontend`, and the keyboard doesn't reach the game in the platform's preview. ## Routing {#routing} On your project's domain: - **`/api/*`** → your backend. WebSockets work under `/api`. If the project has no backend, `/api/*` returns 404. - **`/__gbandit/*`** → reserved for the platform: the [gbandit runtime](/runtime) your frontend loads, and what it talks to. - **everything else** → the static frontend. **Backend-only projects** (no `frontend` declared) route everything to the backend instead, so self-serving apps (SSR frameworks, classic server-rendered apps) work. Backends hibernate when idle. The static frontend keeps serving; the first `/api` request wakes the backend (held, not dropped, while it starts). For backend-only projects, wake-on-request applies to every path. ## Runtime hardening {#hardening} Backends run untrusted code, so the runtime is locked down. Your image must work with all of this: - Runs under **gVisor** (syscall-filtering sandbox). - Forced **UID 65534** regardless of the image's `USER`. Don't require root, and don't write to `$HOME`. - **Read-only root filesystem.** Writable paths: `/tmp` (1Gi, wiped on restart) and `/data` (persistent, sqlite projects only). - **256Mi memory / 500m CPU** limits (512Mi for sqlite projects). JVM/.NET images need their heap flags tuned. - Exactly **1 replica**. There is no horizontal scaling. - No Kubernetes service-account token. - Egress: public internet allowed, private network ranges blocked. ## Databases {#database} There are two ways to get a database, declared in different places in [gbandit.jsonc](#gbandit-jsonc). Your app owns the schema either way (see [Migrations](#migrations)). **Use SQLite unless your app needs Postgres-specific features.** A sleeping SQLite backend wakes faster, since there is no database cluster to start, and the backend gets a higher memory limit. - **A volume on the backend**: `"backend": { ..., "volume": { "sqlite": true } }`. The backend gets a persistent disk at `/data` and `DATABASE_URL=sqlite:///data/db.sqlite`. Memory limit rises to 512Mi. Only the SQLite form exists: `"sqlite": false` (a plain disk) is rejected. - **A managed database on the project**: `"database": { "postgres": true }`. Each environment gets its own database on a managed Postgres cluster, and `DATABASE_URL` is injected. Requires a `backend`. `"postgres": false` is rejected; omit the section instead. A backend cannot have both, since it gets one `DATABASE_URL`. A project without a database declares neither and gets no `DATABASE_URL`. ### Removing a database {#database-removal} Removing the `volume` or the `database` declaration on a project with a provisioned database **removes that environment's database the next time that environment rolls the change out**. This is permanent data loss, so it is guarded: the run is rejected unless you confirm it (interactive runs ask instead). A final backup is attempted before anything is dropped, but a failed backup never blocks the removal, so a broken database can always be removed. Each environment is removed on its own, by the command that reaches it: ```sh gbandit deploy --confirm-database-removal # removes dev's database gbandit promote --confirm-database-removal # removes prod's database ``` Dev's database goes when you deploy the change. Prod's stays until you promote that release; until then prod keeps running the release that still declares it, and keeps its data. Switching from one to the other is the removal of the old database plus a fresh new one, and needs the same confirmation. Nothing is copied across. ### Migrations {#migrations} The platform never runs a migrator. Your app applies its own schema when it starts, using `DATABASE_URL`. The template compiles its `backend/migrations/` into the binary and runs the pending ones at boot. With Prisma, Django, Rails and the like, run the migrator in the container's start command, for example `prisma migrate deploy && node server.js`. A migration that fails at boot takes the container down, and the deploy fails with your app's own error in its output. With managed Postgres the previous version keeps serving. A SQLite backend stops the old version before the new one starts, since only one of them can hold the disk, so it is down until the next deploy fixes the migration. The build receives no database connection. The template's build replays the migrations into a throwaway schema file on its own, so compile-time query checks work without one; a Postgres build that needs a live server for that has to bring its own (see below). ### Upgrading to managed Postgres {#postgres-upgrade} The template ships SQLite. Moving a project to managed Postgres is four changes, and the same `docker build` keeps working on your own machine: 1. `gbandit.jsonc`: delete `volume` from the backend and add `"database": { "postgres": true }`. This is a removal plus an addition, so the deploy needs `--confirm-database-removal` and nothing is copied across (see [Removing a database](#database-removal)). 2. `backend/Cargo.toml`: swap sqlx's `sqlite` feature for `postgres`. 3. `backend/src/main.rs` and `lib.rs`: `PgPool` instead of `SqlitePool`; drop the WAL journal option, keep `sqlx::migrate!()`. Rewrite the migrations that use SQLite-only syntax. 4. `backend/Dockerfile`: the build still has to typecheck `query!` against a migrated schema with no connection from outside, so the builder stage runs a throwaway Postgres of its own. Install and `initdb` it before `COPY` so both layers are cached; only the build step starts it. ```dockerfile FROM rust:1-slim-bookworm AS builder WORKDIR /app # Throwaway Postgres for the build. Installed and initialised before COPY so # the layers survive source changes (about 30s and 370MB, once). RUN apt-get update && apt-get install -y --no-install-recommends postgresql && \ rm -rf /var/lib/apt/lists/* && \ mkdir -p /pgdata && chown postgres /pgdata && \ su postgres -c "/usr/lib/postgresql/15/bin/initdb -D /pgdata --auth=trust --no-locale -E UTF8" COPY . . # Start the server on a unix socket only, replay the migrations into it, and # point sqlx at it for the build. Costs well under a second per build. RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ su postgres -c "/usr/lib/postgresql/15/bin/pg_ctl -D /pgdata -o '-k /tmp -c listen_addresses= -c fsync=off' -w start" && \ for f in migrations/*.sql; do [ -e "$f" ] || continue; su postgres -c "psql -v ON_ERROR_STOP=1 -h /tmp -d postgres -f $f" || exit 1; done && \ DATABASE_URL="postgres://postgres@localhost/postgres?host=/tmp" cargo build && \ cp target/debug/replace_with_project /app/replace_with_project FROM gcr.io/distroless/cc-debian12 WORKDIR /app COPY --from=builder /app/replace_with_project /app/replace_with_project EXPOSE 8080 ENV RUST_BACKTRACE=1 CMD ["./replace_with_project"] ``` Postgres refuses to run as root, hence `su postgres`; the Debian package creates that user. Running the server as your app's runtime user is not an option either, because the builder stage runs as root. A framework that does not typecheck queries at build time (Prisma, Django, Rails and the like) needs none of this: build as usual and migrate at boot. ## Injected environment variables {#env-vars} | Variable | When | Value | | --------------------- | ----------------- | ----------------------------------------------------------- | | `PORT` | always | The port to listen on (currently `8080`). | | `DATABASE_URL` | database projects | Postgres connection string, or `sqlite:///data/db.sqlite`. | | `AUTH_ISSUER` | always | Expected `iss` of platform JWTs. | | `AUTH_AUDIENCE` | always | Expected `aud`: `game:` in prod, `game:dev-` in dev. | | `AUTH_JWKS_URL` | always | JWKS endpoint for verifying JWTs. | | `GBANDIT_ENVIRONMENT` | always | `dev` or `prod`: which environment this is. | Plus your own variables, set per environment with `gbandit env set KEY=VALUE [-e prod]`. They reach the backend, not the build, and a change takes effect the next time the backend starts: on the next deploy (dev) or promote (prod), or when it wakes from sleep. See [Authentication](/auth) for using the `AUTH_*` values. ## Git behaviour {#git} By default (`local_dev.auto_commit: false`) **deploy never touches git**: no commits, no pushes. A dirty tree deploys as it is. With `"auto_commit": true`, every deploy auto-commits a dirty tree and, once the deploy has succeeded, pushes it to origin `main`. A deploy that fails takes its commit back off again, leaving your changes uncommitted in the working tree, so the history only holds commits that deployed. When the outcome isn't known (`--detach`, or the connection to the platform was lost) the commit stays local and the next successful deploy pushes it. A rejected push doesn't fail an already successful deploy; pull and push yourself. Enable it for projects whose repo is dedicated to gbandit; leave it off when deploying from a pre-existing repo you don't want touched. `--message "what changed"` labels the deploy in the history and becomes the commit message. The Pi Agent always deploys this way, whatever `gbandit.jsonc` says. The commit is by your git identity. Without one configured, it is by your gbandit name and a `@users.noreply.gbandit.com` address. ## Publishing to the catalog {#publish} A game is not on [games.gbandit.com](https://games.gbandit.com) until you publish it, and publishing needs two things: a cover image, and a game promoted to prod. The catalog always shows what prod runs, so promoting a new version updates a published game without publishing it again. ```sh gbandit project cover cover.png # PNG, JPEG or WebP, cropped to 16:9 gbandit project publish # list the game in the catalog gbandit project unpublish # take it out again; the game keeps running gbandit project show # title, cover, catalog status and play URL gbandit project show --json # the same, machine-readable ``` All of them take `--project ` to act on a project other than the one in `gbandit.jsonc`. The title comes from `title` in `gbandit.jsonc`, or from the project's Settings page when the file has none. The Settings page can also upload the cover and publish. ## Deploying from a script or agent {#cli-deploy} ```sh gbandit deploy --create --json # create project if needed, machine-readable output gbandit deploy --create --json --detach # return immediately with the pipeline run id ``` - `--create` creates the project on first deploy without prompting. - `--json` emits stable machine-readable JSON on stdout. - `--detach` returns after the pipeline run is created instead of waiting. - `--confirm-database-removal` confirms a deploy that removes a provisioned database (see [Removing a database](#database-removal)). Promoting takes `--json`, `--detach`, `--project` and `--confirm-database-removal`, and nothing else, since there is no source to upload, no project to create and no target to choose: ```sh gbandit promote --json # machine-readable; reports the commit prod # runs and the one it moves to gbandit promote --json --detach # return immediately with the pipeline run id ``` A promotion never asks for confirmation, with one exception: pass `--confirm-database-removal` when the release dev runs no longer declares prod's database. Print this documentation from the CLI at any time: ```sh gbandit docs # index of pages gbandit docs deploy # this page, raw markdown gbandit docs --full # all documentation in one output ``` --- # Authentication {#auth} gbandit runs a shared auth service at `auth.gbandit.com`. Players have one account across every game: a guest at first, Google if they sign in. The account lives in a cookie on the auth service. Each game exchanges that cookie for a **game token**, a JWT scoped to its own project, and verifies it in its backend. You never store passwords or run your own identity. ## Frontend: calling your backend as the player {#token} Load the [gbandit runtime](/runtime) and call your backend through it: ```js const response = await gbandit.fetch("/api/me"); ``` It exchanges the shared cookie for a game token at `auth.gbandit.com`, sends it as `Authorization: Bearer `, and keeps it until shortly before it expires. For anything `fetch` doesn't cover, such as a WebSocket, [`gbandit.getAccessToken()`](/runtime#get-access-token) gives you the token itself. The token's audience comes from your game's address, so it only works on your deployed domain, and the token is only valid for _your_ project. Under a local dev server the runtime asks the dev server for a [test user's](#test-users) token instead (see [Local development](/runtime#local)). A first-time visitor doesn't need to do anything: the auth service creates a **guest** (with a generated username) and sets the shared cookie in the same response, so the call above just works. Guest tokens have `is_anon: true`. When there is no token to send, `gbandit.fetch` rejects with a `gbandit.GbanditError` read from the auth service's [Problem Details](/errors) body: `error.is(slug)` says which one you got, and `error.message` is a sentence you can show the player. The 401 you do need to handle is [`session-expired`](/errors#session-expired). It means the player had a real account here (Google-linked, or one that was merged into another) and its session ended. The auth service won't replace it with a fresh guest, because that would drop a returning player into your game as a stranger with none of their progress. Send them to sign in, and they come straight back on the same account: ```js location.href = gbandit.loginUrl(); ``` Sending a guest there is also how they move to Google. With a new Google account the guest keeps its `sub`, so their progress carries over. If they already have a Google account, the guest is merged into it and they come back with that account's `sub`; progress your game saved under the guest's `sub` stays with the guest. The other answer to send to sign-in is a 429 [`guest-limit-reached`](/errors#guest-limit-reached). The auth service caps how many guests one network address may hold at a time, so a scripted flood cannot fill the database with accounts. A large school behind a single address can reach the cap honestly, and a Google account is the way past it. Guest creation is also rate limited per address at the edge; a plain 429 with no problem body is that limit, and retrying after a moment is the right move. A 403 [`account-suspended`](/errors#account-suspended) means the platform has suspended this player's account. No token, and no fresh guest, will come from this browser until the suspension is lifted. Show the player that they can't play, rather than retrying or sending them to sign in. `await gbandit.signOut()` signs the player out of gbandit and reloads your game, where they start over as a new guest. ## Letting players change their name {#profile} Players pick their username on the auth service, not in your game. If your game shows it, put an edit button next to it that sends the player to their profile, with `redirect` pointing back so the page offers a way back to your game: ```js location.href = gbandit.profileUrl(); ``` The new name is in the `username` claim of the next token you mint. A full page load on the way back does that if you keep the token in memory; if you cache it anywhere longer-lived, fetch a fresh one when the player returns. ## The token {#claims} Tokens are **ES256**-signed JWTs with these claims: | Claim | Meaning | | ------------------- | ---------------------------------------------------------------- | | `sub` | Player id (UUID). **The stable identity**: key your data on this. It is the same in every gbandit game. Tokens never carry the player's email. | | `username` | Current display handle. Can change; display it, don't key on it. | | `aud` | `game:` in prod, `game:dev-` in dev. Must equal `AUTH_AUDIENCE`. | | `iss` | The auth service. Must equal `AUTH_ISSUER`. | | `is_anon` | `true` for guest sessions. | | `principal_kind` | `"human"` for players, `"test_user"` for [test users](#test-users). | | `exp`, `iat`, `jti` | Standard expiry / issued-at / token id. | ## Testing as several players {#test-users} Every project has ten test users, made-up players of its dev environment. `gbandit test-token [1-10]` prints a token for one, so you can call your dev backend as several players at once: ```sh curl -H "Authorization: Bearer $(gbandit test-token 1)" https://dev-.gbandit.com/api/me curl -H "Authorization: Bearer $(gbandit test-token 2 --guest)" https://dev-.gbandit.com/api/me ``` Each number is the same player every time, with a `sub` of its own and the username `test-user-`. `--guest` gives you the guest with that number instead, `test-guest-`, a player of its own. A token lasts 30 minutes, and only dev accepts it, since its audience is dev's. Your backend verifies it like any other token: there is nothing to add for test users. ## Backend: verifying the token {#verify} Your backend receives three environment variables (see the [deploy contract](/deploy#env-vars)): `AUTH_ISSUER`, `AUTH_AUDIENCE`, and `AUTH_JWKS_URL`. Verification is standard JWKS-based JWT validation: fetch the keys from `AUTH_JWKS_URL` (and cache them), then check signature (ES256), issuer, audience, and expiry. ### Node / TypeScript (jose) {#verify-node} ```js import { createRemoteJWKSet, jwtVerify } from "jose"; const jwks = createRemoteJWKSet(new URL(process.env.AUTH_JWKS_URL)); async function verifyToken(token) { const { payload } = await jwtVerify(token, jwks, { issuer: process.env.AUTH_ISSUER, audience: process.env.AUTH_AUDIENCE, }); return payload; // payload.sub is the user id } ``` ### Rust (jsonwebtoken) {#verify-rust} ```rust use jsonwebtoken::{decode, decode_header, jwk::JwkSet, Algorithm, DecodingKey, Validation}; use serde::Deserialize; #[derive(Deserialize)] struct Claims { sub: String, username: Option, is_anon: bool, } // Fetch once at startup and cache; refetch on unknown `kid`. async fn fetch_jwks() -> JwkSet { reqwest::get(std::env::var("AUTH_JWKS_URL").unwrap()) .await.unwrap().json().await.unwrap() } fn verify_token(token: &str, jwks: &JwkSet) -> Result { let kid = decode_header(token).map_err(|e| e.to_string())? .kid.ok_or("missing kid")?; let jwk = jwks.find(&kid).ok_or("unknown kid")?; let key = DecodingKey::from_jwk(jwk).map_err(|e| e.to_string())?; let mut validation = Validation::new(Algorithm::ES256); validation.set_issuer(&[std::env::var("AUTH_ISSUER").unwrap()]); validation.set_audience(&[std::env::var("AUTH_AUDIENCE").unwrap()]); decode::(token, &key, &validation) .map(|data| data.claims) .map_err(|e| e.to_string()) } ``` ### Python (PyJWT) {#verify-python} ```python import os import jwt jwks_client = jwt.PyJWKClient(os.environ["AUTH_JWKS_URL"]) def verify_token(token: str) -> dict: key = jwks_client.get_signing_key_from_jwt(token).key return jwt.decode( token, key, algorithms=["ES256"], issuer=os.environ["AUTH_ISSUER"], audience=os.environ["AUTH_AUDIENCE"], ) # ["sub"] is the user id ``` ## The subtle bugs {#pitfalls} - **Keying on `username`.** Usernames change; `sub` doesn't. Store `sub`, display `username`. - **Skipping audience validation.** Without the `aud` check, a token minted for any other gbandit game verifies against yours. - **Rejecting guests.** `is_anon: true` tokens are valid signed identities. Treat them as players unless your game specifically needs accounts. - **Fetching JWKS per request.** Cache the key set; refetch only when you see an unknown key id. --- # The gbandit runtime {#runtime} The runtime is a small script the platform serves on your game's own address. Load it and you get, without any further code: - **The player**: `gbandit.fetch()` calls your backend as the player, and `gbandit.loginUrl()` and `gbandit.signOut()` send them to sign in and out (see [Authentication](/auth)). - **Player counting**: the heartbeat that counts your players for the [games.gbandit.com](https://games.gbandit.com) ranking (see [Player activity](/heartbeat)). - **Frontend logs**: uncaught errors, unhandled rejections and `console` output go to `gbandit logs frontend` (see [Frontend logs](/frontend-logs)). - **Test users locally**: under `bun run dev` and other local dev servers you play as one of your project's [test users](/auth#test-users), since gbandit sign-in only exists where the game is deployed. - **Keyboard in the preview**: on the platform's Build page your game runs in an iframe. Most game engines cancel `mousedown`, which keeps a click from giving the iframe keyboard focus; the runtime gives it focus instead. Games scaffolded from the gbandit template already load it. Every deploy whose `index.html` doesn't load it prints a warning. ## Loading it {#include} Put this in ``, before your own scripts: ```html ``` It's a same-origin path on your game's domain (`/__gbandit/*` is reserved for the platform). `defer` runs it after the page is parsed but before your own deferred and module scripts, so `gbandit` already exists when your code starts. It's a few kilobytes and is cached by the browser. A tag in ``, or `async`, can run after your code instead, so keep it in ``. With Vite, add `vite-ignore` to the tag. Otherwise Vite warns that it can't bundle a classic script; it leaves the tag alone either way. ## API {#api} The runtime defines one global, `gbandit`. Types for TypeScript are in the template as [`frontend/src/gbandit-runtime.d.ts`](https://github.com/gbandit/game-template/blob/main/frontend/src/gbandit-runtime.d.ts); copy that file into your project. ### `gbandit.fetch(path, init?)` {#fetch} ```js const response = await gbandit.fetch("/api/me"); const me = await response.json(); ``` `fetch` to your own backend as the player. It adds their game token as `Authorization: Bearer `, and if your backend answers 401 it fetches a fresh token and sends the request once more, which covers clock skew and a rotated signing key. A body has to survive being sent twice, so use a string, `FormData` or a `Blob`, not a stream. It resolves only for a 2xx answer. Anything else, including no answer at all, rejects with a `gbandit.GbanditError` read from the [Problem Details](/errors) body your backend or gbandit answered with: - `status`: the HTTP status, or 0 when no response arrived. - `message`: the body's `detail`, a sentence you can show the player. - `type` and `is(slug)`: which [problem](/errors) it was. - `requestId`: the id the request was logged under. It only calls your game's own address and throws a `TypeError` for any other, since the token is the player's identity in your game. The player may have no token: then `fetch` rejects before sending anything, with the same error [`getAccessToken()`](#get-access-token) gives. ### `gbandit.getAccessToken(options?)` {#get-access-token} ```js const token = await gbandit.getAccessToken(); ``` The player's game token, for what `gbandit.fetch` doesn't cover, such as a WebSocket (the `bearer` subprotocol). The runtime keeps it until shortly before it expires, and callers that ask at the same moment share one request, so ask for it each time instead of storing it. If your backend rejects a token the runtime still considers valid, ask again with `{ forceRefresh: true }`. A first-time visitor gets a guest automatically, so a token is almost always available. When there isn't one, the promise rejects with a `GbanditError`: - [`session-expired`](/errors#session-expired) or [`guest-limit-reached`](/errors#guest-limit-reached): send the player to [`loginUrl()`](#login-url). After `session-expired` the runtime keeps refusing until the page loads again, because asking once more would replace the player's account with a new guest. - [`account-suspended`](/errors#account-suspended): the player can't play. Show them `message`. - `status` 0: no response at all, usually a network problem. ### `gbandit.loginUrl(redirect?)` {#login-url} ```js location.href = gbandit.loginUrl(); ``` The sign-in page, which sends the player back to `redirect` (default: the current page) signed in. A guest who signs in keeps their progress; see [Authentication](/auth#token). Navigate to it with a full page load: a router's client-side navigation can't reach it. ### `gbandit.profileUrl(redirect?)` {#profile-url} Where the player changes their username, with a way back to `redirect`. See [Letting players change their name](/auth#profile). ### `gbandit.signOut(redirect?)` {#sign-out} ```js await gbandit.signOut(); ``` Signs the player out of gbandit, in every game, then navigates to `redirect` (default: your game's start page). The next visit gets a new guest. ### `gbandit.reportError(message, metadata?)` {#report-error} ```js gbandit.reportError("level failed to load", { level: 3 }); ``` Sends an error to `gbandit logs frontend` without throwing one. `metadata` is any JSON. ## Local development {#local} On `localhost` or an IP address the runtime assumes a local dev server. It sends no heartbeats and reports no logs (DevTools has them), and it plays the auth service's part with your project's [test users](/auth#test-users): - A local run starts as `test-guest-1`, the way a first visit in prod starts as a guest. A `DEV` badge in the corner says who you are playing as. - `loginUrl()`, and the badge, lead to a page where you pick any `test-user-` or `test-guest-`. `signOut()` goes back to `test-guest-1`. - Tokens come from your dev server, at `POST /__gbandit/token/user/` and `/__gbandit/token/guest/`. Your local backend verifies them against the dev audience like any player's. The template's `frontend/vite.config.ts` does the dev server's part under `bun run dev`. Any other dev server needs to answer four paths: | Path | Answer | | ---- | ------ | | `GET /__gbandit/runtime.js` | The output of `gbandit runtime`, which fetches the runtime from your game's dev address. | | `GET /__gbandit/login` | ``: the runtime draws the page itself. | | `POST /__gbandit/token/user/` | The output of `gbandit test-token --json`. | | `POST /__gbandit/token/guest/` | The output of `gbandit test-token --guest --json`. | `gbandit test-token` needs `gbandit login` and a project that has been deployed once. --- # Player activity {#heartbeat} The [games.gbandit.com](https://games.gbandit.com) catalog ranks games by **weekly active users**, counted from heartbeats that the [gbandit runtime](/runtime) sends from your players' browsers. A game that doesn't load the runtime sends none, so it ranks last however many people play it. Games scaffolded from the gbandit template already load the runtime. For any other game, add the tag in [Loading it](/runtime#include). ## How players are counted {#counting} - The runtime sends a heartbeat when the page loads and then about once a minute while the tab is visible, to `POST /__gbandit/telemetry/heartbeat` on your game's domain, as the player. - The platform decides everything else itself: which game, which player, and when. A game can't send its own counts. - Activity is credited only in **prod**. Heartbeats from dev are accepted and ignored. - A player counts toward the week once they have been active in at least two different minutes. Guests count too, but only two guests per network per game, so many players behind one address count as two. - [Test users](/auth#test-users) are never counted. ## Catalog listing {#catalog} A game is listed on games.gbandit.com once you publish it: promote it to prod, upload a cover image and run `gbandit project publish` (see [Publishing to the catalog](/deploy#publish)). No database is required, and heartbeats only affect _ranking_, never whether a game is listed. --- # Frontend logs {#frontend-logs} `gbandit logs frontend` and the **Frontend** tab on the project's Logs page show what went wrong in your players' browsers. The platform can't see into a browser, so these entries come from the [gbandit runtime](/runtime), which your game loads in its ``. Games scaffolded from the gbandit template already load it. Backend logs need nothing: whatever your container writes to stdout shows up under `gbandit logs backend`. ## What the runtime reports {#reported} - Uncaught errors and unhandled promise rejections, with their stack. - Everything passed to `console.log`, `console.warn` and `console.error`. The original still runs, so DevTools is unaffected. - Whatever you pass to [`gbandit.reportError()`](/runtime#report-error). Each entry records the page's URL and, when the player has a token, **which player** hit it. Entries from a burst (a render loop that throws every frame) go out together, up to 20 at a time. A new frontend **starts the environment's log over** (a deploy for dev, a promote for prod), so what you read always belongs to the code that is live. Under a local dev server the runtime reports nothing; your browser's DevTools has it all. --- # Errors {#errors} Every gbandit API answers a failure with an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details body, `Content-Type: application/problem+json`. That holds for the platform API, the CLI endpoints, and the platform's own answers on your game's address (`/api` while the backend is asleep, a game that doesn't exist, ...). ```json { "type": "https://docs.gbandit.com/errors#nothing-to-promote", "title": "Nothing to promote", "status": 409, "detail": "There is nothing in dev to promote yet. Run `gbandit deploy` first, then promote.", "request_id": "0b6f1c9e-…", "retryable": false } ``` | Member | Meaning | | --- | --- | | `type` | What kind of failure this is. Branch on this, never on `title` or `detail`. `about:blank` means nothing beyond the status code. | | `title` | Short, fixed summary of the type. | | `status` | The HTTP status, repeated. | | `detail` | What happened this time and what to do about it. Written to be shown to a person. | | `request_id` | The id the platform logged the request under. Quote it when you ask for help. | | `retryable` | Whether sending the same request again can succeed. | | `issues` | Field-level problems, each with `path`, `message` and `docs_url`. | | `diagnostics` | Raw technical output, such as git's stderr. Useful for debugging, not for showing by default. | A type can add members of its own; they are listed under it below. Ignore members you don't know. ### platform-error {#platform-error} `5xx`. Something broke on our side, not in your code or your request. The `detail` never says why; quote the `request_id` when you ask for help. Retry when `retryable` is `true`. ## Deploying and promoting {#deploying} ### nothing-to-promote {#nothing-to-promote} `409`. `gbandit promote` with nothing in dev to promote. Run `gbandit deploy` first. ### cli-outdated {#cli-outdated} `426`. The gbandit CLI is older than the oldest version the platform supports. Run `gbandit update`. ### config-missing {#config-missing} `422`. The uploaded source has no `gbandit.jsonc` at its root. See [gbandit.jsonc](/deploy#gbandit-jsonc). ### invalid-config {#invalid-config} `422`. `gbandit.jsonc` does not validate. `issues` lists every problem, each with the `path` in the file, a `message`, and a `docs_url` to the section that explains it. ### google-account-required {#google-account-required} `403`. A guest account tried to run a backend or a database, or to use the AI agent. Log in with Google (`gbandit login`); your projects come with you. ### database-removal-requires-confirmation {#database-removal-requires-confirmation} `409`. The deploy or promotion would remove a provisioned database, which deletes its data. Repeat it with the confirmation; see [Removing a database](/deploy#database-removal). ### project-suspended {#project-suspended} `403`. The project's creator has been suspended, so the project is neither served nor changed. The game's address answers with it too, and a browser gets a page saying the game is suspended. Questions go to abuse@gbandit.com. ## Signing in {#signing-in} These come from `auth.gbandit.com`, including `POST /api/token`; see [Authentication](/auth). ### account-suspended {#account-suspended} `403`. The platform has suspended this account. Signing in again can't help, so don't send the player to the login page. Questions go to abuse@gbandit.com. ### session-expired {#session-expired} `401`. The player's session expired, and it belonged to an account they can sign back in to. Send them to sign in instead of carrying on as a new guest. ### guest-limit-reached {#guest-limit-reached} `429`. The player's network already holds as many guest accounts as it may. Send them to sign in. ### authorization-pending {#authorization-pending} `400`. `gbandit login` polls while the login waits in the browser, and gets one of these four answers until it is done. This one means nobody has approved it yet; poll again after the interval the start of the login gave. ### access-denied {#access-denied} `400`. The person denied the login in the browser. Stop polling. ### expired-token {#expired-token} `400`. The login expired before anyone approved it. Start a new one. ### invalid-grant {#invalid-grant} `400`. The device code names no login, or the login was already completed. Start a new one. ## Project data {#project-data} ### no-database {#no-database} `409`. The environment declares no database, so there is nothing to query. See [Databases](/deploy#database). ### ai-spend-paused {#ai-spend-paused} `402`. The weekly AI budget is used up, so new messages are refused until the reset. Work that is already running may finish. `usage` carries the budget and `reset_at`. ## Your game's address {#game-address} These come from the platform, not from your backend, when a request to your game's domain can't reach it. A browser navigation gets a page instead. ### game-not-found {#game-not-found} `404`. No project has this name. ### nothing-deployed {#nothing-deployed} `404`. The project exists, but nothing has been deployed to this environment. ### no-backend {#no-backend} `404`. The environment has no backend, so nothing answers `/api`. ### game-starting {#game-starting} `503`. The backend was asleep and is starting. Retry after the number of seconds in `Retry-After`. ## Linked repository {#linked-remote} Git operations the agent's workspace runs against your linked repository. ### divergent-history {#divergent-history} `409`. The workspace and the remote both have commits the other doesn't. `workspace` and `remote` say how far ahead each side is and list its latest commits; `remote_main_sha` is the remote's `main`. Choose which side to keep. ### git-auth-rejected {#git-auth-rejected} `403`. The Git host refused the deploy key. Check that the key is installed with write access, then try again. ### git-host-unreachable {#git-host-unreachable} `502`. The Git host could not be reached. Check the remote URL and try again. ### git-failed {#git-failed} `500`. Git failed for another reason, for example a protected branch. `diagnostics` holds git's output. ## The agent {#agent} Messages to the agent that works on your Project. ### conversation-not-found {#conversation-not-found} `404`. The conversation doesn't exist. A conversation starts with its first message; start a new one. --- # Getting started: use the template {#template} `gbandit scaffold` gives you a working project that deploys as it is: a React, Vite and Tailwind frontend, plus a Rust (axum) backend with player [authentication](/auth) already wired up. The backend is opt-in, so deploys are frontend-only until you [enable it](#backend). Starting from a repo you already have instead? Read [deploy an existing app](/getting-started-existing). ## 1. Install the CLI {#install} ```sh curl -fsSL https://github.com/gbandit/cli/releases/latest/download/install.sh | sh gbandit login ``` `gbandit login` signs you in with Google in the browser. You can skip it: `gbandit deploy` then offers a guest account, which can deploy a frontend. ## 2. Scaffold {#scaffold} ```sh gbandit scaffold ``` You are asked for a title and a project slug. The slug becomes your subdomain: `.gbandit.com`. You can also pass them: ```sh gbandit scaffold my-game --title "My Game" # into ./my-game/, no questions gbandit scaffold . # into the current directory ``` Scaffolding only writes files. Nothing exists on the platform until the first deploy. ## 3. Deploy {#deploy} ```sh cd my-game gbandit deploy ``` The first deploy creates the project and puts your game at `https://dev-.gbandit.com`. When you are ready for players: ```sh gbandit promote # https://.gbandit.com runs what dev runs gbandit project cover cover.png # the image players see in the catalog gbandit project publish # list the game on games.gbandit.com ``` ## 4. Develop {#develop} Change the code, run `gbandit deploy`, and refresh `dev-.gbandit.com`. The template's `AGENTS.md` sets coding agents up to work the same way. Useful commands: ```sh gbandit logs backend # or frontend gbandit env set KEY=VALUE # config for your app gbandit test-token 2 # a token to call your dev backend as a test user gbandit docs # this documentation, in your terminal ``` ## 5. Enable the backend {#backend} The template's backend is ready but is not deployed until you declare it: uncomment the `backend` block in `gbandit.jsonc` and deploy. ```jsonc { "project": "my-game", "frontend": { "dockerfile": "frontend/Dockerfile", "context": "frontend" }, "backend": { "dockerfile": "backend/Dockerfile", "context": "backend" }, } ``` Requests to `/api/*` on your game's domain now reach it, and player [authentication](/auth) works. To use it in the frontend, switch to the auth-aware routes marked in `frontend/src/router.ts`. ## 6. Add a database {#database} Uncomment the backend's `volume` in `gbandit.jsonc`. The backend gets a disk and a SQLite `DATABASE_URL`. SQLite is the recommended choice; see [Databases](/deploy#database) for why, and for managed Postgres. ```jsonc { "project": "my-game", "frontend": { "dockerfile": "frontend/Dockerfile", "context": "frontend" }, "backend": { "dockerfile": "backend/Dockerfile", "context": "backend", "volume": { "sqlite": true } }, } ``` Then uncomment the database wiring in the template, marked in `backend/src/config.rs`, `backend/src/lib.rs` and `backend/src/main.rs`. Migrations go in `backend/migrations/`, and the backend applies them itself when it starts; the platform never runs a migrator. The Dockerfile needs no change: its build step replays the migrations into a throwaway file so `query!` typechecks. ## Where to go next {#next} - [Deploy contract](/deploy): the full reference for `gbandit.jsonc`, routing, runtime limits, environment variables and git behaviour. - [Authentication](/auth): how the template's player auth works once the backend is enabled. - [The gbandit runtime](/runtime): the script the template's `index.html` loads for the player's token, player counting and frontend logs. --- # Getting started: deploy an existing app {#existing} How to take a repo you already have and get it running on gbandit. The steps are short; the work is usually in making your container satisfy the [runtime constraints](#pitfalls). Every step is covered in full in the [deploy contract](/deploy). Starting fresh instead? [Use the template](/getting-started-template). ## 1. Install the CLI {#install} ```sh curl -fsSL https://github.com/gbandit/cli/releases/latest/download/install.sh | sh gbandit login ``` `gbandit login` signs you in with Google in the browser. You can skip it: `gbandit deploy` then offers a guest account, which can deploy a frontend. ## 2. Add gbandit.jsonc {#gbandit-jsonc} At the repo root, declare your project and its components. The file is JSONC, so comments are allowed: ```jsonc { "project": "my-app", "frontend": { "dockerfile": "frontend/Dockerfile", "context": "frontend" }, "backend": { "dockerfile": "backend/Dockerfile", "context": "backend" }, } ``` Declare only what you have: a pure client-side game needs just `frontend`, an SSR app just `backend` (it then receives all traffic, not only `/api/*`). Both paths are relative to the repo root and point wherever your Dockerfiles actually live. Set `context` to `"."` if backend and frontend share code. All fields: [gbandit.jsonc reference](/deploy#gbandit-jsonc). The slug becomes your subdomain: `https://.gbandit.com` (prod) and `https://dev-.gbandit.com` (dev). By default deploy **never touches git**: no commits, no pushes, and a dirty tree deploys as it is. It is safe to point at a repo you already use. See [Git behaviour](/deploy#git). ## 3. Make the backend fit the contract {#backend} The platform builds your declared Dockerfile as it is, so any language works. The `context` you declared is the docker build context, so `COPY` whatever you need from inside it. See [Components](/deploy#components). ### The pitfalls that break ports {#pitfalls} Backends run untrusted code, so the runtime is hardened. These are the constraints real ports fail on: - **Listen on `$PORT`** (currently `8080`), not your app's default port. - **Declare your database.** `DATABASE_URL` is only set when `gbandit.jsonc` declares one (see [step 5](#database)). - **Read-only root filesystem.** Write only to `/tmp`, and to `/data` for SQLite projects. Caches, lock files and logs must not land anywhere else. - **Forced UID 65534.** Don't require root, and don't write to `$HOME`. - **256Mi memory and 500m CPU** (512Mi for SQLite projects), and exactly **1 replica**. JVM and .NET images need their heap flags tuned. The full list, including gVisor and egress rules: [Runtime hardening](/deploy#hardening). ## 4. Frontend {#frontend} If you declared a `frontend`, its image must contain the **built** site. The image is never run; its files are extracted and served. Everything except `/api/*` goes to the static site, and `/api/*` goes to your backend. See [Static frontend](/deploy#static-frontend) and [Routing](/deploy#routing). Load the [gbandit runtime](/runtime) in your `index.html`'s ``, before your own scripts: ```html ``` It gives you the player's token, counts your players for the catalog and sends browser errors to `gbandit logs frontend`. A deploy without it warns. ## 5. Database {#database} Give the backend a volume, `"backend": { ..., "volume": { "sqlite": true } }`, or declare a managed database, `"database": { "postgres": true }`. Either one needs a `backend`; omit both for no database. [Databases](/deploy#database) explains which to pick. Removing the declaration later removes the database on the next confirmed deploy; see [Removing a database](/deploy#database-removal). `DATABASE_URL` is set either way. Your app owns its schema and the platform never runs a migrator, so run yours in the container's start command, for example `prisma migrate deploy && node server.js`. See [Migrations](/deploy#migrations). Other config your app needs: `gbandit env set KEY=VALUE`. ## 6. Deploy {#deploy} ```sh gbandit deploy --create ``` `--create` creates the project on the first deploy without asking. The deploy goes to dev; `gbandit promote` then points prod at what dev runs. If the app does not come up, check `gbandit logs backend` and the [pitfalls](#pitfalls) above. ## Where to go next {#next} - [Deploy contract](/deploy): the full reference this guide links into. - [Authentication](/auth): let players log in with their gbandit account. - [The gbandit runtime](/runtime): one script tag in your `` gives you the player's token, ranking in the games.gbandit.com catalog, and `gbandit logs frontend`, which stays empty without it.