{
  "name": "steam-game",
  "title": "Steam and Godot games - online backend",
  "description": "Online backend for a native game (Godot, Steam): players sign in with Steam or as guests without a Gipity account, then call the game's functions, database and leaderboards from the Gipity Godot addon. Steam's relay and lobbies carry the live race traffic.",
  "guid": "sk_plat_stgm",
  "category": "App development",
  "requiredTools": [
    "add",
    "file_write",
    "project_deploy"
  ],
  "content": "# Steam and Godot games - online backend\n\nA native game (Godot 4, exported for Windows, Linux or Steam Deck) uses Gipity as its backend: player accounts, server functions, a database, and leaderboards with ghost recordings. The game talks to it over HTTPS with the **Gipity Godot addon**. There is no server to run.\n\n**Who does what:**\n\n| Need | Use |\n|---|---|\n| Player sign-in (Steam, guest) | Gipity app players (this skill) |\n| Leaderboards (scores or times, daily/weekly/season), personal bests, replays | The `leaderboard` kit (see the [leaderboard](leaderboard.md) skill) |\n| Saved progress, race results, ratings, game rules on the server | Your own functions + database ([app-development](app-development.md)) |\n| Live race traffic (car positions, 30 Hz) | Steam Networking Sockets through GodotSteam: Steam's relay network, free, global, NAT-free |\n| Lobbies, invites, matchmaking between Steam players | Steam lobbies (GodotSteam) |\n\nDon't route per-tick game state through Gipity: functions are request/response, not a game relay. Report outcomes (race results, lap times) to Gipity when they happen.\n\n## Set up the backend\n\nKeep the backend in a `backend/` folder of the game repo, with a `.gdignore` file in it so Godot doesn't import it:\n\n```bash\nmkdir backend && touch backend/.gdignore && cd backend\ngipity init my-game            # link this folder to a new Gipity project\ngipity add api                 # functions + database (no web frontend)\ngipity add leaderboard         # optional: leaderboards with replays\ngipity project auth app        # players sign in to THIS game, no Gipity account needed\ngipity project auth --unique-names on   # optional: no two players share a display name\ngipity deploy dev\n```\n\n`gipity project auth` modes: `gipity` (default: Sign in with Gipity only), `app` (Steam and guest players only), `both`. App sign-in is refused until the mode is `app` or `both`. `--unique-names on|off` sets whether display names must be unique in this game (see [Display names](#display-names)); it is off by default.\n\n### Steam sign-in keys\n\nSteam sign-in verifies each player's session ticket with Valve using the game's **Steamworks publisher Web API key**. The developer creates it in Steamworks: Users & Permissions > Manage Groups > (a group with the app) > Create WebAPI Key. Store it and the AppID as project secrets:\n\n```bash\ngipity secrets set STEAM_WEB_API_KEY <publisher key>\ngipity secrets set STEAM_APP_ID <appid>\n```\n\nOnly the developer can get these; ask them. Without them, Steam sign-in returns a setup error, and guest sign-in still works. Valve's test AppID 480 (Spacewar) has no publisher key you can use, so Steam sign-in needs the game's real AppID.\n\n## Add the addon to the game\n\n1. Copy `examples/godot/addons/gipity/` from the Gipity registry (https://github.com/GipityAI/registry/tree/main/examples/godot) into the Godot project's `addons/` folder and enable **Gipity** under Project Settings > Plugins. That adds the `Gipity` autoload. To fetch just that folder: `git clone --depth 1 --filter=blob:none --sparse https://github.com/GipityAI/registry.git && git -C registry sparse-checkout set examples/godot`. The same folder has Gip Racer, a demo game with its backend, to copy from.\n2. Set **Project Settings > gipity/app_guid** to the project guid (`gipity project info`).\n3. For Steam: install GodotSteam, call `Steam.steamInitEx()` at startup, and `Steam.run_callbacks()` every frame.\n\n```gdscript\nfunc _ready():\n\tvar r = await Gipity.sign_in_steam()\n\tif not r.ok:\n\t\tr = await Gipity.sign_in_guest(\"Player\")   # non-Steam build, or Steam unavailable\n\tif r.ok:\n\t\tprint(\"Signed in as \", Gipity.player.displayName)\n\nfunc save_result(result: Dictionary):\n\tvar r = await Gipity.call_function(\"race-result\", result)   # r.data = the function's return value\n```\n\nEvery call returns `{ ok, data, error, code, status, offline }` and never throws. `code` is the server's error code (e.g. `DISPLAY_NAME_REJECTED`, `PLAYER_CLEANUP_FAILED`, `GAME_VERSION_TOO_OLD`), `\"\"` when there is none; a refused leaderboard run keeps `ok: true` with `data.accepted: false` and its code in `code`. `offline` means the server couldn't be reached: the game must keep working without online features. Leaderboard submissions made offline are queued and sent after the next sign-in; a queued run the server then refuses is dropped and reported through the `queued_run_dropped(run, code, reason)` signal. Runs carry the game's `application/config/version` as `gameVersion`. The addon renews expired player tokens by signing in again.\n\nOther calls: `link_steam()` (attach Steam to the current guest, keeping their progress), `sign_out()`, `rename_player(name)` (the player's name in this game, for guests and Steam players; `null` clears it), `check_name(name)` (is a name free, with suggestions; works before sign-in), `Gipity.player.nameConflict` (true when the game should ask the player to pick a name), `delete_player()` (account deletion requests; on `PLAYER_CLEANUP_FAILED` the session is kept so you can retry), and the `Gipity.leaderboard` client (`submit`, `top`, `around_me`, `me`, `friends`, `friends_steam`, `ghost`, `boards`, `seasons`).\n\n## Players in your functions\n\nA signed-in player is an ordinary signed-in user to your functions: `auth: user` functions accept them, and `ctx.auth.userGuid` / `ctx.auth.displayName` are set (`displayName` is the player's name in this game: a name they set, else their Steam persona). Key per-player rows on `ctx.auth.userGuid`, as in any app.\n\n`ctx.auth.identity` says how they signed in: `{ provider: 'steam', id: '<SteamID64>' }` or `{ provider: 'guest', id: '<hash>' }`, and `null` for Gipity users. Use the Steam id to match Steam friends to players.\n\nA player belongs to one game. The same Steam account is a different player in each of your games, and a player token only works on its own game's API.\n\n```js\n// functions/race-result.js  (gipity.yaml: auth: user, tables: [race_results])\nexport default async function raceResult(ctx, { db, guid }) {\n  const { track, position, timeMs } = ctx.body;\n  await db.insert('race_results', {\n    id: guid('rr'), user_guid: ctx.auth.userGuid, steam_id: ctx.auth.identity?.id ?? null,\n    track, position, time_ms: timeMs,\n  });\n  return { saved: true };\n}\n```\n\nThe server can't see the game, so treat everything a client sends as a claim: check it for plausibility (bounds, consistency, rate) and keep a way to ban. The leaderboard kit does this for scores.\n\n## Calling the API without the addon\n\nAny client can use plain HTTPS against `https://a.gipity.ai/api/<appGuid>`:\n\n| Call | Body | Returns |\n|---|---|---|\n| `POST /auth/steam` | `{ ticket }`: the bytes from `GetAuthTicketForWebApi(\"gipity\")`, hex-encoded. Add `\"link\": true` with a guest's Bearer token to link. | `{ data: { token, expiresIn, user } }` |\n| `POST /auth/guest` | `{ deviceSecret, displayName? }`: 32-256 random characters the client generates once and keeps | same |\n| `GET /auth/player` | Bearer token | `{ data: { guid, displayName, provider, providerUserId } }` |\n| `PATCH /auth/player` | `{ displayName }` (1-40 characters, or `null` to clear) with a player's Bearer token | `{ data: { guid, displayName, nameConflict } }`. 409 `DISPLAY_NAME_TAKEN` when names are unique and it's taken |\n| `GET /auth/player/name-available?name=` | none, or a player's Bearer token (their own name counts as free). 30 checks a minute per IP | `{ data: { name, available, uniqueNames, suggestions } }`: up to 5 free names that pass the filter when taken |\n| `DELETE /auth/player` | Bearer token | `{ data: { deleted: true } }` after the app's cleanup ran (see below) |\n| `POST /fn/<name>` | Bearer token, JSON body | `{ data: <function return> }` |\n\nThe sign-in `user` is `{ guid, displayName, avatarUrl, provider, providerUserId, isNew, nameConflict }`.\n\nThe ticket identity must be exactly `gipity`. Player tokens last 24 hours; there is no refresh token, so sign in again (a fresh Steam ticket, or the same device secret) when one expires or a call returns 401.\n\n### Display names\n\n**The filter.** A name the player chooses (a guest's `displayName` at sign-in, or any `PATCH /auth/player`) is checked against a profanity and slur filter that sees through leetspeak, spacing and lookalike letters. A refused name is a 400 with `code: 'DISPLAY_NAME_REJECTED'` and a message to show; nothing is silently rewritten, so ask the player for another name. Steam persona names are used as Steam provides them (only control characters are dropped and the length is capped at 40).\n\n**Steam players' name in your game.** A Steam player is named after their Steam persona, refreshed on every sign-in. `PATCH /auth/player` gives them a name for this game instead: it wins over the persona on every later sign-in (a persona change on Steam doesn't touch it), and `{ displayName: null }` clears it, going back to their current persona. Linking Steam to a guest keeps a name the guest chose; a guest without one takes the persona. Functions see the effective name as `ctx.auth.displayName`, and the leaderboard kit stores it with each new personal best.\n\n**Unique names (opt-in).** `gipity project auth --unique-names on` makes display names unique among the game's players. Names are compared ignoring case, accents, spacing, invisible characters and lookalike letters (fullwidth, Cyrillic and Greek), so \"Turbo\", \"TURBO\", \"Ｔｕｒｂｏ\" and \"Тurbо\" are one name; leetspeak is not folded (\"TURB0\" is a different name). The database enforces it, so two players racing for the same name can't both get it. With it on:\n\n- A guest signing in with a taken name, or a `PATCH /auth/player` to one, is a 409 with `code: 'DISPLAY_NAME_TAKEN'` (no player is created). Check first with `GET /auth/player/name-available?name=...`, which answers before sign-in and offers free suggestions (`TURBO` taken: `TURBO27`, `TURBO418`, ...).\n- A Steam player whose persona is taken still signs in: they get `\"<persona> #<last 4 digits of their SteamID>\"` and `nameConflict: true`. Ask them to pick a name. Their next sign-in retries the plain persona.\n- Turning it on when players already share a name never fails: the earliest player keeps it, and the later ones keep theirs too but sign in with `nameConflict: true` until they rename. The CLI reports how many. Turning it off stops checking.\n- A deleted player's name is free again.\n\n### Deleting a player\n\n`DELETE /auth/player` erases the player (for account-deletion requests). Your app's data about them is erased first: every function declared with `hooks: [user_deleted]` in `gipity.yaml` runs with `ctx.trigger = { type: 'user_deleted', userGuid }`, and the leaderboard kit ships one. If any of them fails, nothing is deleted and the call returns 502 `PLAYER_CLEANUP_FAILED`; retry it later. Keep your own per-player tables clean the same way:\n\n```yaml\n# gipity.yaml, under the functions phase\nfunction_definitions:\n  - name: forget-player\n    auth: member            # the platform calls it; players and visitors can't\n    hooks: [user_deleted]\n    tables: [race_results]\n```\n\n```js\n// functions/forget-player.js\nexport default async function forgetPlayer(ctx, { db }) {\n  if (ctx.trigger?.type !== 'user_deleted') return { error: 'Runs when a player is deleted.' };\n  await db.query('DELETE FROM race_results WHERE user_guid = $1', [ctx.trigger.userGuid]);\n  return { ok: true };\n}\n```\n\n## Common mistakes\n\n- **Sending live positions through functions.** Use Steam Networking Sockets for the race itself. Functions are for outcomes.\n- **Forgetting `gipity project auth app`.** Sign-in returns 403 until the mode allows app players.\n- **A ticket for the wrong identity.** Request it with `Steam.getAuthTicketForWebApi(\"gipity\")`, not the no-argument session ticket.\n- **Not calling `Steam.run_callbacks()`.** The ticket never arrives, and sign-in times out after 10 seconds.\n- **Requiring the network to start the game.** Sign in in the background and let every online feature fail soft.\n- **Treating `nameConflict` as a failed sign-in.** The player is signed in; show a rename prompt (`check_name` + `rename_player`) when it is true.\n- **Keeping per-player rows without a `user_deleted` hook.** A deleted player's rows (and their name on them) stay behind. Add a hook that deletes them, as above.\n"
}
