Connect to a backend service
Fill in the Backend Service fields
A backend service stores your save inside an envelope of its own, and the Backend Service fields of the Server (HTTP) manager describe that envelope. The address and body templates carry the following placeholders.
| Placeholder | What it stands for |
|---|---|
{url} | The manager's own address, from Release Url. |
{slot} | The slot being read or written, which is usually the player id. |
{id} | The persistent asset's own id, which is what tells two saves of one player apart. |
{token} | Whatever your code put in the manager's Authorization, or in HttpPersistenceManager.DefaultAuthorization. |
{payload} | The save itself. Bodies only. |
Response Payload Path is the dotted route to your save inside the service's answer, with numbers for positions in an array. Leave the load and clear bodies empty for a REST backend, and fill them in for an RPC, the way PlayFab and Nakama need.
Sign-in is your game's job. As soon as it returns, set
HttpPersistenceManager.DefaultAuthorization to the session token, which is what
{token} stands for.
What {slot} is free to hold depends on whether the service can name the player itself:
- It takes the player from the token, the way PlayFab and LootLocker do. The address names the save alone, so slots are yours and work as they do anywhere else.
- The address has to name the player, which is what the other recipes here do. Turn on
Use Slot and set
Slots.GlobalSlotto the player id, so{slot}carries it. That asset then holds one save per player.
To have both on a service of the second kind, put an endpoint of your own in front of it: it reads the player from the token, which leaves the slot to you.
PlayFab
| Field | Value |
|---|---|
| Release Url | https://TITLEID.playfabapi.com |
| Load Url Template | {url}/Client/GetUserData |
| Load Body Template | {"Keys":["save"]} |
| Save Url Template | {url}/Client/UpdateUserData |
| Save Body Template | {"Data":{"save":"{payload}"}} |
| Clear Url Template | {url}/Client/UpdateUserData |
| Clear Body Template | {"KeysToRemove":["save"]} |
| Load / Save / Clear Verb | Post |
| Response Payload Path | data.Data.save.Value |
| Extra Headers | X-Authorization = {token} |
| Not Found Means | An Error: PlayFab answers a successful, empty result for a save that is not there, so a 404 is always a fault |
| Empty Answer Means | No Save: that empty result is what a first launch looks like |
The session ticket names the player, so the slot names the save file rather than the player. To give a player several slots, vary the key and the response path together.
400 PlayerCreationDisabled. Create the player from your own server, or
untick that box under Settings > API Features while you test.Firebase Firestore
| Field | Value |
|---|---|
| Release Url | https://firestore.googleapis.com/v1/projects/PROJECT/databases/(default)/documents |
| Load Url Template | {url}/players/{slot}/saves/{id} |
| Save Url Template | {url}/players/{slot}/saves/{id}?updateMask.fieldPaths=save |
| Clear Url Template | {url}/players/{slot}/saves/{id} |
| Load Verb | Get |
| Save Verb | Patch |
| Clear Verb | Delete |
| Save Body Template | {"fields":{"save":{"stringValue":"{payload}"}}} |
| Response Payload Path | fields.save.stringValue |
| Extra Headers | none; the standard Authorization header carries Bearer <idToken> |
| Not Found Means | No Save, the default: Firestore answers 404 for a document that does not exist |
request.auth.uid against the path. The test-mode ruleset makes every save public.Supabase
Supabase serves a table through PostgREST. Create a saves table with an
owner uuid primary key defaulting to auth.uid(), a save text
column, and a row-level policy for the owner.
| Field | Value |
|---|---|
| Release Url | https://PROJECT.supabase.co/rest/v1 |
| Load Url Template | {url}/saves?select=save, with row-level security returning only this player's row |
| Save Url Template | {url}/saves?on_conflict=owner |
| Clear Url Template | {url}/saves?owner=eq.{slot}, with the player id in the slot |
| Load Verb | Get |
| Save Verb | Post |
| Clear Verb | Delete |
| Save Body Template | {"save":"{payload}"} |
| Response Payload Path | 0.save |
| Extra Headers | apikey = your anon key, and Prefer = resolution=merge-duplicates |
| Not Found Means | An Error: PostgREST answers an empty array for a row that is not there, so a 404 is always a fault |
| Empty Answer Means | No Save: that empty array is what a first launch looks like |
service_role.Firebase Realtime Database
| Field | Value |
|---|---|
| Release Url | https://PROJECT-default-rtdb.firebaseio.com |
| Load / Save / Clear Url Template | {url}/players/{slot}/saves/{id}.json?auth={token} |
| Load / Save / Clear Verb | Get / Put / Delete, the defaults |
| Save Body Template | {"save":"{payload}"} |
| Response Payload Path | save |
| Extra Headers | none; the token rides in the address |
| Empty Answer Means | No Save: a path holding nothing answers 200 with null |
Nakama
| Field | Value |
|---|---|
| Release Url | https://HOST:7350/v2 |
| Load / Save Url Template | {url}/storage |
| Clear Url Template | {url}/storage/delete |
| Load / Save / Clear Verb | Post / Put / Put |
| Load Body Template | {"object_ids":[{"collection":"saves","key":"{id}","user_id":"{slot}"}]} |
| Save Body Template | {"objects":[{"collection":"saves","key":"{id}","value":"{payload}","permissionRead":1,"permissionWrite":1}]} |
| Clear Body Template | {"object_ids":[{"collection":"saves","key":"{id}"}]} |
| Response Payload Path | objects.0.value |
| Empty Answer Means | No Save: a key that was never written comes back as an empty objects list |
LootLocker
| Field | Value |
|---|---|
| Release Url | https://api.lootlocker.com/game/v1/player/storage |
| Load Url Template | {url}?key=save_{slot} |
| Save Url Template | {url} |
| Clear Url Template | {url}?key=save_{slot} |
| Load / Save / Clear Verb | Get / Post / Delete |
| Save Body Template | {"payload":[{"key":"save_{slot}","value":"{payload}","is_public":false,"order":1}]} |
| Response Payload Path | payload.value |
| Extra Headers | x-session-token = {token} |
| Not Found Means | An Error: a key nobody wrote answers 200, so a 404 is a real fault |
| Empty Answer Means | No Save: a key nobody wrote answers 200 with "payload":null |
order. A flat body answers 200 and stores nothing.Clear() returned over what a load reports.PocketBase
| Field | Value |
|---|---|
| Release Url | https://HOST/api/collections/saves |
| Load Url Template | {url}/records?filter=owner="{slot}"&fields=save |
| Clear Url Template | {url}/records/{slot} |
| Load / Clear Verb | Get / Delete, the defaults |
| Response Payload Path | items.0.save |
| Empty Answer Means | No Save: a filter matching nothing answers 200 with an empty items list |
PATCH {url}/records/{slot} serves every save, or add a route of your own that upserts.Appwrite
| Field | Value |
|---|---|
| Release Url | https://HOST/v1/tablesdb/DB/tables/TABLE |
| Load / Save / Clear Url Template | {url}/rows/{slot} |
| Load / Save / Clear Verb | Get / Put / Delete |
| Save Body Template | {"data":{"save":"{payload}"}} |
| Response Payload Path | save |
| Extra Headers | X-Appwrite-Project = your project id |
Any GraphQL endpoint
| Field | Value |
|---|---|
| Release Url | https://HOST/graphql |
| Load / Save / Clear Url Template | {url} |
| Load / Save / Clear Verb | Post |
| Load Body Template | {"query":"query($o:String!){saves(owner:$o){save}}","variables":{"o":"{slot}"}} |
| Save Body Template | {"query":"mutation($o:String!,$s:String!){insert_saves_one(owner:$o,save:$s){owner}}","variables":{"o":"{slot}","s":"{payload}"}} |
| Clear Body Template | {"query":"mutation($o:String!){delete_saves(owner:$o)}","variables":{"o":"{slot}"}} |
| Response Payload Path | data.saves.0.save |
200 carrying
no data, which reaches the payload path as nothing. Leave Empty Answer Means on
Corrupt, so the load reports it instead of reading it as a first launch.Any other REST backend
To fill in the fields for a service this page doesn't list, find four things in its own documentation:
- Which endpoint reads, writes and deletes one record, and with which verb.
- What a write body looks like, with the data in their example replaced by
{payload}and nothing escaped. - Where the data sits in a read answer, written with dots, and numbers for positions in an array.
- How the service wants the token. A standard
Authorizationheader needs nothing extra.
Use your own database
Put an endpoint of your own in front of the database. The game never reaches MySQL, Postgres or anything else directly: a connection string in a build is in your players' hands, and WebGL and consoles can't open one at all.
Leave every Backend Service field empty, and the manager sends the payload verbatim to
url/slot/id. Your endpoint then needs the three routes
Server (HTTP) lists, keyed by the player, the asset and the
slot. Verify the token on each of them, and take the player from the token, never from the request
body.
LONGBLOB or a
BYTEA column. With compression or encryption on it is not text, and a column that re-encodes
it produces corrupt saves.Other services
Xano, Directus, Strapi, Parse Server, Back4App and Convex take the Appwrite shape; Hasura, Nhost and AWS AppSync take the GraphQL one. Steam and Unity Cloud Save have a manager of their own.
What an absent save looks like
Two settings decide how the manager reads an answer that carries no save. The first, Empty Answer Means, covers an answer that parsed but holds nothing at the payload path:
| Value | An answer holding nothing at the payload path |
|---|---|
Corrupt (default) |
Reported: the load comes back Corrupt and On Load Failed fires. With an offline
cache on, the cached save is kept. |
No Save |
A first launch: the target starts on its defaults with no failure event. A mistyped payload path reads the same way. |
A 404 means both "the address is right and holds nothing" and "unknown route, rejected
token". The second setting, Not Found Means, settles which one applies:
| Value | A 404 to a load |
|---|---|
No Save (default) | There is no save. The target starts on its defaults, and an offline cache entry for that slot is dropped. |
An Error | Something is wrong. The load fails and retries, and a cache entry is kept and stays playable. |