Docs / Learn / Who is the player: guest vs account

Who is the player: guest vs account

Give the player an identity without a sign-up form, and confirm the same identity survives a restart.

In step 3 you connected and read back a player_id. That id came from a token. This step is about where the token comes from, and the cheapest way to get one.

You do not have to register

A player does not need a username and password to start. Guest auth mints a real player_id on first launch from a device-held secret, with no credentials and no form. The same device resumes the same player on every later launch.

The client owns one secret. On first run it generates a device_secret (at least 32 bytes from a cryptographic RNG, base64-encoded) plus a stable device_id, and stores both in secure device storage. It posts them to POST /api/v1/auth/guest; the server stores only a salted, peppered HMAC of the secret, never the secret itself, and returns a token pair. Later launches post the same pair and resume the same player (create-or-resume). Treat device_secret like a password: generate it once, keep it on the device, never log or transmit it elsewhere.

This is still server-authoritative. The client presents intent (a device secret); the server decides identity and hands back the token that every other call is bound to.

When to upgrade to a full account

A guest is only as safe as the secret on that one device. Lose the device, lose the account. Upgrade when the player has something worth protecting or wants it on more than one device:

  • The player wants to keep progress across a reinstall or a new device.
  • You want a recoverable login (username and password, or a linked provider).
  • The stakes rise: purchases, competitive standing, anything you would not want lost with a wiped phone.

POST /api/v1/auth/guest/upgrade (authenticated with the guest's own token) claims the guest with a username and password. The player_id, progress, wallets, and inventory are all preserved; the device secret is revoked. Only an unclaimed guest may upgrade. Every SDK below exposes this as upgrade_guest / upgradeGuest / UpgradeGuest.

See the Authentication guide for the full flow, error table, and abuse controls. The design rationale is ADR 0002 (registration is open, not required) and ADR 0004 (the game declares guest auth, the operator peppers it).

Enabling guest auth (server, Lua-first)

Guest auth is opt-in and fails closed. Endpoints return 403 guest_auth_disabled until two independent parties agree: the game declares the toggle, and the operator supplies a pepper. Either half alone leaves it off.

The game half is one Lua global. Put it in match.lua for a single-mode game, or in config.lua for a multi-mode game (deployment-wide globals live in the manifest, not the per-mode scripts):

guest_auth = true

The operator half differs by deployment. This is the only place the two paths diverge.

Cloud. Declare guest_auth = true and nothing else. Each environment is provisioned with a stable per-environment pepper automatically, so the same bundle is off in dev (no pepper yet) and on in prod. You never handle the secret.

Self-hosted. You own both halves. Declare guest_auth = true in Lua, and supply the pepper yourself. There is no ASOBI_GUEST_AUTH env var; the pepper is the switch. Provide it via the env var on the asobi_lua image:

ASOBI_GUEST_VERIFIER_PEPPER=<at-least-32-random-bytes>

Or, when you build a release from source, via sys.config (a key-id to pepper map, so you can rotate while old guests still resume):

{asobi, [
    {guest_verifier_pepper, #{<<"v1">> => <<"a-32-byte-or-longer-secret......">>}},
    {guest_verifier_key_id, <<"v1">>}
]}

Keep the pepper in a secret manager, never in the bundle. See Configuration for the optional abuse and retention controls (guest_unlinked_cap, guest_reap_after).

Signing in as a guest (client)

One call per SDK, below. The signature is identical on cloud and self-hosted; only the base server URL you built the client with differs (http://localhost:8084 self-hosted, your environment URL on cloud). In every case you generate and persist device_id and device_secret yourself once, then pass the same pair on every launch. The response carries created: true on the first call and omits it on a resume; the player_id is the same both times.

-- Auth is callback-style.
local asobi = require("asobi.client")
local client = asobi.create("localhost", 8084)

client.auth.guest(client, device_id, device_secret, function(data, err)
    if err then print("guest sign-in failed: " .. tostring(err.error)) return end
    print("guest " .. tostring(data.player_id) .. " created=" .. tostring(data.created))
end)

-- Upgrade later with client.auth.upgrade_guest(client, "chosen_name", "pass1234", cb)
# Auth is await-style on the Asobi autoload.
Asobi.host = "localhost"
Asobi.port = 8084

var resp := await Asobi.auth.guest(device_id, device_secret)
if resp.has("error"):
    push_error("guest sign-in failed: %s" % resp.error)
else:
    print("guest %s created=%s" % [resp.player_id, resp.get("created", false)])

# Generate the secret once, e.g. Marshalls.raw_to_base64(bytes), and persist it.
# Upgrade with await Asobi.auth.upgrade_guest("player1", "secret123")
// Async Task on client.Auth.
var client = new AsobiClient("localhost", port: 8084);

var resp = await client.Auth.GuestAsync(deviceId, deviceSecret);
Debug.Log($"guest {resp.player_id} created={resp.created}");

// Upgrade with await client.Auth.UpgradeGuestAsync("player1", "secret123")
// Callback delegate on UAsobiAuth.
UAsobiClient* Client = NewObject<UAsobiClient>();
Client->SetBaseUrl(TEXT("http://localhost:8084"));

UAsobiAuth* Auth = NewObject<UAsobiAuth>();
Auth->Init(Client);

Auth->Guest(DeviceId, DeviceSecret, OnGuest);

// OnGuest is an FOnAsobiAuthResponse; the returned tokens are stored on the client automatically.
// Upgrade with Auth->UpgradeGuest(TEXT("player1"), TEXT("secret"), OnUpgrade)
// Async Future on client.auth.
final client = AsobiClient('localhost', port: 8084);

final auth = await client.auth.guest(deviceId, deviceSecret);
print('guest ${auth.playerId}');

// The typed AuthResponse exposes playerId but not created, so detect a resume here
// by matching playerId across launches (exactly the checkpoint below).
// Upgrade with await client.auth.upgradeGuest('player1', 'secret123')
// Async, params object with raw wire field names.
const sdk = new Asobi({ baseUrl: "http://localhost:8084" });

const session = await sdk.auth.guest({ device_id: deviceId, device_secret: deviceSecret });
console.log(`guest ${session.player_id} created=${session.created}`);

// guest() stores the returned tokens exactly like login(), so the rest of the SDK is authenticated immediately.
// Upgrade with await sdk.auth.upgradeGuest({ username: "alice", password: "s3cret-password" })
-- Blocking, returns data, err.
local asobi = require("asobi")
local client = asobi.new({host = "localhost", port = 8084})

local data, err = asobi.auth.guest(client, device_id, device_secret)
if err then error("guest auth failed: " .. err.error) end
print("guest " .. tostring(data.player_id) .. " created=" .. tostring(data.created))

-- Auth is blocking, so call it at startup, not inside love.update.
-- Upgrade with asobi.auth.upgrade_guest(client, "chosen_name", "pass1234")

Checkpoint

Prove the same identity survives a restart.

  1. First run: generate a device_id and device_secret, store both on the device, and call guest(...). Log the player_id; created is true.
  2. Stop the client completely.
  3. Second run: read the stored device_id and device_secret back, call guest(...) again with the same pair. Log the player_id.

You see the pass when the two player_id values match and the second response has no created field. If instead you get 403 guest_auth_disabled, the server half is missing: check that guest_auth = true is declared, and (self-hosted) that ASOBI_GUEST_VERIFIER_PEPPER is set. If a resume returns 401 invalid_device_secret, the client is not persisting the same secret between runs.

Next: Step 5 - Storing data: the storage API, not SQL

Now that a player has a durable id, give them something durable to keep.