A 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.
Who does what:
| Need | Use |
|---|---|
| Player sign-in (Steam, guest) | Gipity app players (this skill) |
| Leaderboards (scores or times, daily/weekly/season), personal bests, replays | The leaderboard kit (see the leaderboard skill) |
| Saved progress, race results, ratings, game rules on the server | Your own functions + database (app-development) |
| Live race traffic (car positions, 30 Hz) | Steam Networking Sockets through GodotSteam: Steam's relay network, free, global, NAT-free |
| Lobbies, invites, matchmaking between Steam players | Steam lobbies (GodotSteam) |
Don'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.
Set up the backend
Keep the backend in a backend/ folder of the game repo, with a .gdignore file in it so Godot doesn't import it:
mkdir backend && touch backend/.gdignore && cd backend
gipity init my-game # link this folder to a new Gipity project
gipity add api # functions + database (no web frontend)
gipity add leaderboard # optional: leaderboards with replays
gipity project auth app # players sign in to THIS game, no Gipity account needed
gipity project auth --unique-names on # optional: no two players share a display name
gipity deploy dev
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); it is off by default.
Steam sign-in keys
Steam 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:
gipity secrets set STEAM_WEB_API_KEY <publisher key>
gipity secrets set STEAM_APP_ID <appid>
Only 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.
Add the addon to the game
- Copy
examples/godot/addons/gipity/from the Gipity registry (https://github.com/GipityAI/registry/tree/main/examples/godot) into the Godot project'saddons/folder and enable Gipity under Project Settings > Plugins. That adds theGipityautoload. 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. - Set Project Settings > gipity/app_guid to the project guid (
gipity project info). - For Steam: install GodotSteam, call
Steam.steamInitEx()at startup, andSteam.run_callbacks()every frame.
func _ready():
var r = await Gipity.sign_in_steam()
if not r.ok:
r = await Gipity.sign_in_guest("Player") # non-Steam build, or Steam unavailable
if r.ok:
print("Signed in as ", Gipity.player.displayName)
func save_result(result: Dictionary):
var r = await Gipity.call_function("race-result", result) # r.data = the function's return value
Every 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.
Other 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).
Players in your functions
A 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.
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.
A 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.
// functions/race-result.js (gipity.yaml: auth: user, tables: [race_results])
export default async function raceResult(ctx, { db, guid }) {
const { track, position, timeMs } = ctx.body;
await db.insert('race_results', {
id: guid('rr'), user_guid: ctx.auth.userGuid, steam_id: ctx.auth.identity?.id ?? null,
track, position, time_ms: timeMs,
});
return { saved: true };
}
The 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.
Calling the API without the addon
Any client can use plain HTTPS against https://a.gipity.ai/api/<appGuid>:
| Call | Body | Returns |
|---|---|---|
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 } } |
POST /auth/guest |
{ deviceSecret, displayName? }: 32-256 random characters the client generates once and keeps |
same |
GET /auth/player |
Bearer token | { data: { guid, displayName, provider, providerUserId } } |
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 |
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 |
DELETE /auth/player |
Bearer token | { data: { deleted: true } } after the app's cleanup ran (see below) |
POST /fn/<name> |
Bearer token, JSON body | { data: <function return> } |
The sign-in user is { guid, displayName, avatarUrl, provider, providerUserId, isNew, nameConflict }.
The 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.
Display names
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).
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.
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", "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:
- A guest signing in with a taken name, or a
PATCH /auth/playerto one, is a 409 withcode: 'DISPLAY_NAME_TAKEN'(no player is created). Check first withGET /auth/player/name-available?name=..., which answers before sign-in and offers free suggestions (TURBOtaken:TURBO27,TURBO418, ...). - A Steam player whose persona is taken still signs in: they get
"<persona> #<last 4 digits of their SteamID>"andnameConflict: true. Ask them to pick a name. Their next sign-in retries the plain persona. - 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: trueuntil they rename. The CLI reports how many. Turning it off stops checking. - A deleted player's name is free again.
Deleting a player
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:
# gipity.yaml, under the functions phase
function_definitions:
- name: forget-player
auth: member # the platform calls it; players and visitors can't
hooks: [user_deleted]
tables: [race_results]
// functions/forget-player.js
export default async function forgetPlayer(ctx, { db }) {
if (ctx.trigger?.type !== 'user_deleted') return { error: 'Runs when a player is deleted.' };
await db.query('DELETE FROM race_results WHERE user_guid = $1', [ctx.trigger.userGuid]);
return { ok: true };
}
Common mistakes
- Sending live positions through functions. Use Steam Networking Sockets for the race itself. Functions are for outcomes.
- Forgetting
gipity project auth app. Sign-in returns 403 until the mode allows app players. - A ticket for the wrong identity. Request it with
Steam.getAuthTicketForWebApi("gipity"), not the no-argument session ticket. - Not calling
Steam.run_callbacks(). The ticket never arrives, and sign-in times out after 10 seconds. - Requiring the network to start the game. Sign in in the background and let every online feature fail soft.
- Treating
nameConflictas a failed sign-in. The player is signed in; show a rename prompt (check_name+rename_player) when it is true. - Keeping per-player rows without a
user_deletedhook. A deleted player's rows (and their name on them) stay behind. Add a hook that deletes them, as above.