---
title: "Unity Gaming Services ops"
slug: /workflows/ugs-ops/
markdown: /workflows/ugs-ops.md
section: live-services
tier: 3
summary: "Auth, Cloud Code, Cloud Save, Lobby/Relay and environments in production."
sources: ['ugs.md']
canonical: https://unityworkflowlibrary.dev/workflows/ugs-ops/
read_first: /read-first.md
---

> Read first, on every prompt: [non-destructive rules](/read-first.md).

# Unity Gaming Services ops

> Auth, Cloud Code, Cloud Save, Lobby/Relay and environments in production.

Applies to any Unity project using UGS (Authentication, Cloud Save, Cloud Code, Lobby, Relay,
Leaderboards, Economy, Remote Config). Verified on a mobile card game with `ugs` CLI 1.9.0.

## UGS-CLI-VS-DASHBOARD — know what is automatable before you plan a manual step

Install `npm i -g ugs`; `ugs login` stores the service-account key locally (encrypted). Config is
per-machine: `ugs config set project-id <guid>` + `ugs config set environment-name <env>`.

**UGS-CLI-ONE-LOGIN:** one key on the machine. Logging into project B's SA *replaces*
project A's. `ugs env list` / `scripts list` 403 on the "right" project-id means this
login is a different account, not a missing script. `ugs status` does not print which
SA it is — check by listing something you know exists. Never `ugs deploy` until
`scripts list` shows *this* project's names.

## UGS-PLAYER-NAMES-NO-FILTER — Auth names are format-only

`UpdatePlayerNameAsync` rejects empty / spaces / length > 50 and appends `#1234`.
There is **no** profanity filter and no name-specific UGS safety product (Vivox Safe
Text is chat). Unity's own sample ships a client `ProfanityManager` for that reason.

Route player-chosen writes through Cloud Code (`PlayerNamesApi` defaults to
`serviceToken`). Filter must be two-tier or you will block `Nigel` for `nig`:

- **EXACT** — short/ambiguous stems (`nig`, `ass`, `hell`, `dick`) as the whole
  compact name or a separator-split token only. Never substring, never prefix
  (`nigel`.startsWith(`nig`)).
- **CONTAINS** — long distinctive slurs with no innocent supersets (`fuck`,
  `nigger`, `asshole`). Check `grape`/`torpedo`/`analysis`/`japan`/`raccoon`
  before adding a stem.
- **ALWAYS_ALLOW** — whole *uncollapsed* form only. Repeat-collapse (`nigger` →
  `niger`) must not hit the allow list.

Access Control `Deny` `Write` on `urn:ugs:social:/v1/names/*` is the hacked-client
lock (`ugs access upsert-project-policy`, needs Project Resource Policy Editor).
GET + `autoGenerate` may still create a name; treat that as a read-side effect.

**Config-as-files (put these in the repo, deploy with `ugs deploy <path>`):** Cloud Code scripts
and modules, Economy, Remote Config, Leaderboards, Cloud Save *indexes*, Triggers, Scheduler,
Access policies, Matchmaker, CCD. `ugs fetch <path>` pulls the live config back down, so an
existing dashboard-configured project can be imported into source control in one command.

**Imperative (CLI, but not file-deployable):** `ugs player list|get|disable|enable|delete`,
`ugs lobby`, `ugs env list|create`.

## UGS-ADMIN-EDITOR-GM — dashboard + CLI + Admin/Trusted APIs, not a hidden GM SDK

Support / editor player-ops have three first-party doors. There is no secret "dashboard scrape" API
on top of these:

1. **Dashboard** Development → Products → **Player Management**: list by playerId, Cloud Save tab
   (default + protected), disable / enable / delete. Economy shows per-player currency/inventory
   *only if* the player actually has Economy data.
2. **CLI:** `ugs player list|get|disable|enable|delete` (imperative, not file-deploy).
3. **REST Admin APIs** (`services.docs.unity.com`) and C# `com.unity.services.apis`:
   - **Admin** client — `SetServiceAccount(key, secret)`: Player Auth, Cloud Save, Economy
     *configuration*, Leaderboards, Remote Config. Intended for Editor tooling and support apps.
   - **Trusted / Server** client — `SignInWithServiceAccount` then *other-player* Cloud Save +
     Economy currency/inventory. This is how you grant/reset gold and items from the Editor.
   Gate with `UNITY_EDITOR`. Never ship `ENABLE_RUNTIME_ADMIN_APIS` /
   `ENABLE_RUNTIME_TRUSTED_APIS` in a player build. Service accounts work here (unlike Build
   Automation — **UBA-EDITOR-TRIGGER** in the editor tooling page). Roles are the dashboard product names (Cloud Save Data
   Editor, Economy Resource Editor, Player Authentication Admin, Unity Environments Viewer).

**IAP receipts are not a UGS admin list.** Unity IAP talks to the stores. Analytics may show
purchase events. Economy Real Money Purchases appear only if you grant through Economy. Google
Play / App Store Server APIs are a separate auth path.

**What these APIs cannot see:** PlayerPrefs and any client-only cache. A GM view of Cloud
Save/Economy will disagree with the HUD until that data lives in UGS.

**Grant-on-login:** write a **protected** `pending_grants` key (Trusted/Admin); Cloud Code consumes
it on the next authenticated call. Do not try to mutate PlayerPrefs on a device you do not have.

**Dashboard-only — no CLI module exists:** **Analytics** (custom event schemas, funnels,
dashboards), Authentication provider setup (Apple/Google/etc.), IAP/store products. Plan these as
manual steps; do not promise to script them. Check `ugs --help` before assuming — the module list
is the authority, and it grows.

**Role gotcha:** `ugs deploy` for Cloud Code needs BOTH *Cloud Code Editor* and *Cloud Code Script
Publisher*. Editor alone uploads then 403s "Not authorized" on the publish step, which reads like
an auth failure but is a missing role. Grant service-account roles **per work area**, not all at
once. Never paste the secret into chat, a file, or a commit — `ugs login` only.

**Official SA product roles (dashboard names, not informal "Economy Admin"):**
cloud.unity.com → organization → Administration → Service Accounts → the account →
**Manage product roles** → pick the *project* (not org-only). Then grant the product's own
names: Economy *Resource* Editor/Viewer; Cloud Code Editor / Viewer / Publisher; Leaderboards
Admin/Viewer; plus Admin: Unity Environments Viewer, Project Resource Policy Editor/Reader.
"Economy Admin" and other invented titles 403 even when the account looks fully loaded.

If `ugs deploy`'s batched publish 500s ("unknown server error"), publish each script individually:
`ugs cloud-code scripts publish <name>`.

## UGS-PROFILE-SERVICE-FIRST — never PlayerPrefs-first for account data

Durable player profile is a **service**, then a cache. Not the other way around.

Wallet, inventory, cosmetics, XP, energy, loadout, IAP entitlements, daily-login streak — anything
that must survive reinstall, show in a GM tool, or be granted on login — is owned by UGS
(**protected Cloud Save**, written only by Cloud Code / Trusted / Admin). Wire that path **before**
the HUD looks finished.

`PlayerPrefs` is allowed only as:

1. **Last-known HUD paint** so the menu can draw before hydrate returns, and
2. **Unsigned / service-down fallback** (greybox, InitFailed). No new grants that the cloud
   did not confirm. When the service returns, **cloud wins** — do not push a prefs wallet over
   an existing protected `wallet`.

Do **not**:

- Ship `PlayerPrefs.SetInt("gold")` as the account, with a comment "Economy later"
- Editor-seed 2500 gold into prefs that later migrates into the real anonymous player
- Client-write Cloud Save default keys for currency because "prefs plus a push" felt done
- Local-grant on Cloud Code timeout (double-pay / split-brain). Fail closed, retry.

Device settings (graphics, volume, hotkeys) stay in PlayerPrefs — those are not the account.

## UGS-CC-EXPORT-OVERWRITES-PARAMS — set `handler.params` AFTER the function

```javascript
module.exports.params = { action: { type: "STRING", required: true } };
module.exports = async ({ params }) => { ... };   // WIPES .params
```

The second assignment replaces the whole `exports` object. Dashboard/`ugs get` then
shows `params: []`. Undeclared args are **dropped**, so `params.action` is undefined
and the script falls through to its default (energy `status`). The client bar stays
full and `spent` is false — "no energy" on a full bar. Attach params on the function
**after** it exists:

```javascript
const handler = async ({ params, context, logger }) => { ... };
handler.params = { action: { type: "STRING", required: true } };
module.exports = handler;
```

Verify with `ugs cloud-code scripts get <name>` — `params` must list the fields, not `[]`.

## UGS-ACCOUNT-DELETE-CHAIN — in-app delete is a store gate, not a SignOut

Apple 5.1.1(v) and Play user-data rules need a **discoverable** in-app delete (Settings),
a **confirm** (never one-tap), and a real wipe of the UGS player + associated data.
Steam, PlayStation and Xbox mandate it too. This is the store-mandated exception to
NEVER-DESTRUCTIVE on the [read-first rules](/read-first/): build and run exactly this mandated flow,
and confirm with the user before executing it.

Order, while the *old* session is still signed in:

1. Block if lobby/match is live — do not auto-dissolve other players. Toast "leave first".
2. Clear persisted rejoin **before** sign-out (the leave still needs the old PlayerId).
3. Wipe Cloud Save (default + protected) and own leaderboard row (Cloud Code).
4. `AnalyticsService.RequestDataDeletion` — Unity retries offline.
5. SIWA: revoke the Apple refresh token **before** step 6 (hook until the provider lands).
6. `AuthenticationService.DeleteAccountAsync` for **anonymous too** — guest Cloud Save /
   Economy / player name is still personal data. CTL/a mobile card game skip guests; that leaves orphans.
7. `SignOut(true)` + `ClearSessionToken` (DeleteAccount already signs out; do both).
8. Wipe local account prefs only (wallet/energy/cosmetics/name). Keep device GameSettings.
9. Fresh anonymous sign-in. Fail after a successful step 6 still reports deleted.

On Auth failure: do **not** wipe local — the same player can retry. Re-entrancy guard.
If step 3 already succeeded, the live account now has empty cloud — snapshot the payload in the
wipe script's return (or a short-lived backup key) so a failed step 6 can restore. Swallowing
wipe *errors* is fine; swallowing wipe *success* then failing Auth is silent progress loss.
Play Console still wants a web deletion URL; that is dashboard, not code.

## UNITY65-UGS-AUTHORING-OBSOLETE — drop editor authoring, keep the dashboard/CLI catalog

Unity 6.5 marks `ProjectWindowUtil.CreateAssetWithContent(..., Action<int>)` obsolete-as-error
(CS0619; replacement is `CreateAssetWithTextContent` + `Action<EntityId>`). Several UGS
client packages still call the old overload from their **Editor Authoring** assemblies
(`com.unity.services.economy`, `com.unity.services.leaderboards` 2.3.3). Runtime assemblies
are fine.

If dashboard/CLI already owns the catalog (currencies, boards, scripts), **do not take that
client package just to read/write at runtime.** Grant and read through Cloud Code; keep a
local display cache if the HUD needs a number immediately. Embedding a runtime-only strip
of the package is the other legal path when you truly need the C# SDK. Do not patch
`Library/PackageCache`.

## UGS-ANALYTICS-SILENT-DROP — the SDK ships opted-OUT and an unschema'd event vanishes

`com.unity.services.analytics` (verified 6.3.0, Unity 6000.3). Analytics is the one UGS service
where **"my code runs clean" and "data arrives" are completely independent**. Three ways it drops
everything without a single client-side error:

1. **It ships opted-OUT.** `AnalyticsService.Instance.StartDataCollection` is mandatory — until
   it is called, every `RecordEvent` is a no-op. Nothing warns you. Call it off whatever your
   "UGS is up" signal is (an auth event is the usual one; it also guarantees Unity Services is
   initialized).
2. **A schema must exist in the dashboard or the event is discarded server-side**, silently — the
   SDK's own `RecordEvent` docs say so. And schemas are **per environment**, so a dev-only schema
   means release builds throw the data away while the client looks perfectly healthy. Create every
   event in dev AND production (see UGS-ENV-DEV-FIRST). Schemas are dashboard-only — there is no
   CLI module (UGS-CLI-VS-DASHBOARD), so this is always a manual step you must hand back.
3. **`AnalyticsService.Instance` THROWS** `ServicesInitializationException` when Unity Services
   isn't initialized. Never call it bare; gate on your own "collection started" flag.

**Never name a custom event after a STANDARD event.** The SDK auto-sends its own set, and standard
events are **read-only in the Event Manager** — you cannot add parameters to them, so your custom
event can neither be edited nor carry its data, and what does land silently mixes into Unity's
metric. As of analytics 6.3.0 the list is `clientDevice`, `gameEnded`, `gameRunning`, `gameStarted`,
`newPlayer`, `sdkStart`, `acquisitionSource`, `adImpression`, `transactionFailed`. Do not trust that
list to stay current — grep the installed package for `PushStandardEventStart(` (plus the
`base("...")` event subclasses) and check before you name anything. `gameStarted` is the one that
bites, because it is the obvious name for "player started a game" and it is already taken by
"the app launched". Note the **reserved *parameter* names list in the docs does NOT cover event
names** — checking it is not the same check.

Two more traps when writing the wrapper:

- **`CustomEvent.Add` throws `ArgumentException`** for anything outside
  string/int/long/float/double/bool/DateTime. Build the event **inside** the try/catch, not at the
  call site — otherwise the throw escapes into whatever pressed the button. Populate through a
  callback so construction is guarded too.
- **`Event.Name` is `internal`**, and `RecordEvent` clears the instance for reuse. Pass the event
  name alongside the event if you want to log it.

**Consent API fork — check before choosing.** `Start/StopDataCollection` become `[Obsolete]` when
`ENABLE_UNITY_CONSENT` is defined (Unity's newer Developer Data framework,
`UnityEngine.UnityConsent.EndUserConsent.SetConsentState`). That define comes from a consent module
being present, NOT from the analytics package — grep the project before assuming. Mixing the two
throws `InvalidOperationException` at runtime: once `StartDataCollection` has locked in the original
flow, a later `SetConsentState` blows up.

**The Analytics Debug Panel double-counts.** `Services → Analytics → Debug Panel` is the right tool
for verifying events client-side (it shows name, payload and Queued → Uploading → Uploaded), but if
it is open when the service initialises — open the panel, then enter play mode, or re-enter play
mode with it open — **every row renders twice.** `AnalyticsService` subscribes the panel's stored
callbacks in two places (`SubscribeDebugEvents` and `Initialize`), so each event reaches the window
twice. Display only; the payload and the upload are single. The tell is that SDK-internal rows
(`sdkStart`, `clientDevice`, `gameRunning`) and the `- Upload Finished (2xx)` markers double as
well, which no game-side bug could cause. **Close and reopen the panel** to reset it. Never diagnose
a suspected double-fire from panel row counts — check your own per-record log line, or the
dashboard's Event Browser.

**Auto-starting collection is a store-paperwork decision, not just a code one** — it must be
declared in the Play Console Data Safety form and the App Store privacy labels. Say so in the
report; the developer files it, you cannot.

## UGS-ENV-DEV-FIRST — dev is the DEFAULT, production is an opt-in define

```csharp
using Unity.Services.Core.Environments;   // without this: CS1061 no SetEnvironmentName

#if MYGAME_ENV_PROD && !UNITY_EDITOR       // one project-specific define, set on the store target only
    public const string TargetEnvironment = "production";
#else
    public const string TargetEnvironment = "dev";
#endif

var options = new InitializationOptions;
options.SetEnvironmentName(TargetEnvironment);
Debug.Log($"UGS environment: {TargetEnvironment}");   // NOT a strippable log wrapper — see below
await UnityServices.InitializeAsync(options);
```

**Do not key this off `DEVELOPMENT_BUILD`.** TestFlight / internal / QA builds are ordinary
*release* builds, so `#if DEVELOPMENT_BUILD ? dev : production` sends every pre-release test
straight at live player data — the exact opposite of the intent. Invert it: an explicit define
opts INTO production, and anything that forgets the define lands in dev, which is the harmless
direction to fail in. Set the define only on the store build target (in Unity Build Automation:
Advanced → Scripting Define Symbols) — never in committed `ProjectSettings` define lists, which
apply to the editor too. `&& !UNITY_EDITOR` is the backstop for exactly that leak.

Log the resolved environment at boot with a **non-strippable** call. If the project routes logs
through a `[Conditional]` wrapper, that line disappears from device builds and you lose the only
evidence of which backend a TestFlight build is talking to.

Deploy → play-verify in `dev` → re-run the same `ugs deploy` against `production`. The files are
identical; only `ugs config set environment-name` changes.

**Propagate in the same slice — divergence is a bug, not a holding state.** Once a change works
in dev, promoting it is part of that change, not a follow-up to be asked about. A stale
production environment only misbehaves in release builds, which is the hardest place to notice
and the only place real players live (a mobile card game: production sat a day behind with a stricter
anti-cheat floor, so every short match in a real build silently earned nothing). Skip promotion
only when the dev change is knowingly unfinished — and say so out loud rather than silently.

`environment-name` is **machine state**, not a per-command flag: whatever it was last set to is
what the next `ugs deploy` targets. Always restore it after touching production, and verify
parity by fetching the live script and diffing the markers you changed — "deployed" is not
"published" is not "active".

**Consequences to state out loud before anyone tests:** environments are fully separate backends —
the editor gets a *fresh anonymous player* (empty balances, no stats), and an editor/dev build
**cannot matchmake with a release build**. A tester reporting "my XP is gone" after this lands is
seeing the environment split, not data loss.

## CLOUDCODE-CLIENT-POINTER — the client sends a pointer, never a fact

A client-authoritative call is a client-authored number. Instead, have the client send only an
**id that indexes a record it did not write** (lobby id, session id, match id), and derive every
fact server-side from service-owned data: membership from the lobby roster, timing from the
service's `created` timestamp, outcome from a stamp written by the authoritative host/server.

- **Never gate on LIVE membership of a session/lobby/party the player is about to leave.** Rosters
  are mutable: returning to the menu removes the player within milliseconds, while a post-match
  submit typically fires seconds later (settle delay + retries). A `roster.find(p => p.id ===
  caller)` check therefore rejects everyone who leaves promptly — deterministically, not rarely.
  Have the authority stamp the **participant list** into the same record it already writes at
  match end, and accept the caller from that stamp *or* the live roster. Still zero client input,
  and it costs no extra request when it rides an existing write.
- Currency/progression keys go in Cloud Save's **protected** access class — client-readable,
  client-unwritable. Read them back with `LoadOptions(new ProtectedReadAccessClassOptions)`.
- Rails belong in the script, not the client: idempotency on the match/session id (pays once,
  ever), a minimum interval between paying calls, a minimum record age. Tunables live in a `CONFIG`
  object at the top of the script so retuning is a republish, not a client build.
- **IDEMPOTENCY-RECORD-ORDER-BY-RETRY-COST.** Read-modify-write is not transactional, so order
  writes by what a retry costs. Ahead of any effect that would DOUBLE-PAY on a retry (an XP grant),
  write the "already claimed" flag FIRST — a mid-way failure then under-pays, which support can
  fix. Ahead of effects that are safe to repeat (a `keepBest` leaderboard write, a map key), write
  the record LAST — a flag written first burns the id when the later write fails, and the client
  can never retry it (a card game: `matchesPlayed` climbing while the board stayed empty).
- When a peer-hosted device must assert something (no dedicated server yet), say so explicitly in
  the script header as accepted leeway, and keep the derivation server-side so replacing the
  stamp writer later needs no script change.

## UGS-CLOUDSAVE-NAMESPACES — default and protected are two different stores

`Data.Player.SaveAsync` / `LoadAsync` = **default** class (client-writable unless Access Control
Deny Write). Cloud Code `setProtectedItem` / `getProtectedItems` = **protected** class.
`LoadAsync` without `new LoadOptions(new ProtectedReadAccessClassOptions)` **does not see**
protected keys. Client `DeleteAsync` on default does not delete protected.

Writing the same key name in both = two copies. Cloud Code reads one, the HUD or a GM tool may
show the other, and a grant to protected never reaches a client that only `LoadAsync`s default.
Pick one class per key. Wipe must delete **both** (UGS-ACCOUNT-DELETE-CHAIN step 3).

## CLOUDSAVE-NO-CROSS-CLASS-FALLBACK — a protected read never falls back to the default class

`if (protected == null) value = await readDefault(...)` is a laundering machine. The default class
is client-WRITABLE by design, so any server path that reads it and then writes the result back with
`setProtectedItem` promotes a value the player authored into a value the server trusts — no packet
crafting, just the public SDK. Absence of a protected value is a FACT (the server never wrote one),
never a reason to go looking in the player's own store.

Same rule for any snapshot/restore pair: a wipe-then-restore that reads with a default fallback and
restores with `writeProtected` is that fallback with two extra hops. Re-validate every restored key
against the same rails the original write went through, and never restore a *grant queue* at all —
a queue is an instruction to credit, so restoring one re-credits it.

(a mobile strategy game: a client wrote `pending_grants` to its own default class, called the account
-deletion endpoint whose snapshot loop had this fallback, then called sync — which restored it to
protected and credited it uncapped. Unlimited premium currency from an unmodified client.)

## ABSENCE-IS-NOT-PERMISSION — never gate a seeding branch on a missing key

"No wallet row yet, so trust the client's migrate numbers" is a grant whose only lock is the
existence of a key. Two things pick that lock, and both are ordinary:

- **Anything that DELETES the key re-arms the branch.** A wipe/reset endpoint, a support tool, a
  GDPR delete — each becomes an economy exploit by side effect. If the account survives the wipe
  (the playerId is still signed in), the "one-shot" seed is repeatable at will.
- **A reader that swallows exceptions cannot tell absent from failed.** `catch (err) { return null; }`
  makes a transient outage indistinguishable from a new account, so a read hiccup rewrites an
  established player's durable profile with whatever their client last cached.

Gate one-time seeding on a durable POSITIVE marker (`migrate_done`) written by the seed itself, and
make the reader distinguish the two cases — a failed read aborts the call, it never seeds. Validate
every entitlement token the seed accepts against the server catalog; a cap on the numbers is not a
check on the item list beside them.

Companion for the id: a client-minted transaction/match id compared against a SINGLE last-id slot is
not idempotency — a fresh id passes every time, and two alternating ids defeat it forever. The id
must be server-issued, single-use, and deleted on redemption (CLOUDCODE-CLIENT-POINTER).

## CLOUDCODE-LOG-ATTRS — one key/value pair per object, and the message carries the answer

```js
logger.info(`XP AWARDED +${xp} to ${name} [${playerId}]`, { playerId }, { name }, { xp }); // ✅
logger.info("XP awarded", { playerId, name, xp });                                        // ❌ drops fields
```

A single multi-key object **silently loses** all but one field — no error, no warning.

Two more that cost real debugging time:

- The dashboard log **list shows the message string only**; attributes need an expand. Put the
  outcome, the human-readable name and the amount in the message (prefix by outcome —
  `AWARDED` / `REJECTED` / `DUPLICATE` / `RETRY` — so the list is scannable and searchable).
- Logs are **append-only history**. Republishing a script never rewrites lines already emitted, so
  "the logs look identical after my fix" is expected until a *new* invocation runs. Confirm a
  deploy landed with `ugs cloud-code scripts list` / the active version number, not by re-reading
  old log lines.
- Get the human name server-side (e.g. off the lobby roster's `DisplayName`) — support tickets
  arrive as "I'm MyPC", never as a GUID. Reading it from a service record costs no extra request
  and does not breach the pointer rule; keep it descriptive-only, never decide anything from it.
- Logs age out. Keep a durable per-player audit trail in a protected Cloud Save key (last N
  awards, newest first) — it doubles as the idempotency memory.

## Economy is not a ledger, and it is not safe by default

- **UGS Economy ships CLIENT-AUTHORITATIVE until you deploy an Access Control policy** — a modified
  client mints currency. It also has **no ledger and no reversal**, so it must never hold the balance:
  keep the balance in protected Cloud Save keys written by Cloud Code, and use Economy only to verify
  store receipts. Shipping a soft currency on Economy's defaults is a free money printer with no audit
  trail to reverse it with.
- **Render an IAP price from the store's product metadata at runtime, never from authored copy.** A
  price label on a store card is a placeholder: an authored `"$4.99"` is wrong in every region but one
  and is a store-review rejection, because Apple and Google each require THEIR localised price string
  to be the one displayed. And the grant must come from a **server-verified receipt** — a client-side
  `Amount` is a client-authored number.
- **Version-gate a SERVER behaviour change on a client-declared capability flag + a `CONFIG` switch.**
  When a backend rule change would make a shipped client render a lie (an older build drawing
  `canClaim: false` as the word CLAIMED), have the new client DECLARE that it understands the new state
  (`gateAware: true`) and have the server enforce the rule only for callers that declare it. Flip the
  switch once the new client is everywhere — a config retune plus republish, no client update. This is
  what lets a functional change reach dev AND production in one slice instead of stranding one env.
  (the mirror page covers the client↔client wire-shape version gate; this is the server-side twin.)


## Session/DA connect time vs UI gates

- **Time out a UI gate ABOVE the slowest measured success of the call it covers.** A first
  Distributed Authority session of the day measured ~60s (sign-in → QoS probe → DA allocation);
  a 30s "searching…" gate released mid-connect and a HEALTHY connect completed behind a screen that
  had already said it failed. Keep the release-on-timeout (a lost event must never strand a player
  behind a cover) — just measure before choosing the number. (a card game.)
- Killing a client process leaves a **ghost session membership** (anonymous auth = same player id per
  device): an immediate rejoin fails with "player is already a member of the lobby" until the
  server-side disconnect timeout evicts it. Reconnect flows need the reconnect API, not a plain join;
  test harnesses should host a fresh session after killing a client.

## CLOUDCODE-ACCESSTOKEN-IS-THE-PLAYER — `context.accessToken` carries the CALLER's permissions

A Cloud Code script that builds a service client with `accessToken` inherits the player's rights,
so a project that denies players a service (Leaderboards writes) has denied its own script. The C#
side sees a bare `Unknown`; the 403 is visible only from inside the script. Player-scoped data
(Cloud Save) is correct on `accessToken`; cross-player services (Leaderboards) use
`context.serviceToken`. (a card game, `SubmitMatchResult.js`.)

## UGS-POLICY-VERIFIED-BY-CHEATING — a deployed access policy protects nothing until you try the cheat

Three traps each yield a policy that deploys cleanly, reads back verbatim, and blocks nothing:
the URN must match the REAL request path (one wildcard segment short matches nothing); a token
minted BEFORE the deploy is unaffected, so test with a fresh sign-in; `Action` accepts only `*` /
`Read` / `Write`, and a service-wide `Write` deny can take reads with it. The only verification is
attempting the forbidden call with a freshly minted player token and watching it fail. (a card game: `ugs deploy` succeeded and a client still wrote 999999 to the board.)

## CLOUDSAVE-GETAS-REJECTS-UNKNOWN-MEMBERS — a client mirror of a server blob must tolerate fields it lacks

`GetAs<T>` defaults to `MissingMemberHandling.Error`: any field the server wrote that the client
struct does not declare throws, and the throw aborts the whole load so the "loaded" latch never
sets. Pass `new DeserializationSettings { MissingMemberHandling = Ignore }` on every `GetAs`, and
keep mirrors minimal — never add server-rail fields to the client to make the error go away.
(a card game: `lastSubmitMs` in the stats blob broke every boot.)

## UGS-LOBBY-RELAY-NETCODE-WIRING — the Lobby + Relay + NetCode-for-Entities seams

One package layout only: `com.unity.services.multiplayer` — adding `com.unity.services.relay` /
`.lobby` beside it duplicates the types (CS0433), and `RELAY_SDK_INSTALLED` stays undefined so the
`RelayServerData(Allocation, "dtls")` convenience constructor is not there (build the data by hand).
`NetworkStreamReceiveSystem.DriverConstructor` is installed BEFORE `CreateServerWorld` /
`CreateClientWorld` — NetCode reads the static in world `OnCreate`. Lobbies are evicted ~30 s without
a heartbeat; every host flow heartbeats on the same cadence. The join code stays `Public` so a
client can read it before membership (a `Member` key forces a lobby join first and re-introduces
the "rejected login bumps the player count" bug). Every Lobby/Relay call goes through ONE rate
limiter — a raw `LobbyService.Instance.*` from a tool or system is a 429 with no UI recovery.
(a project `docs/pitfalls/ugs-relay-lobby.md`; the Mirror/UTP equivalents are in the mirror page.)

## TELEMETRY-OUTLIVES-ITS-CONFIG  - an event row is immutable; what it points AT is not

An analytics row is written once and read months later. The server config it references is TUNABLE
and gets retuned. So an event that carries only a KEY into that config - a calendar day, a tier id,
a difficulty band, a reward slot - silently re-points: every historical row is now read as whatever
that key maps to TODAY, and nothing in the data says the meaning moved.

- **Carry the resolved IDENTITY beside the key**, not instead of it. The key answers "which slot",
  the identity answers "what was actually given" - the granted thing's own stable id and kind, the
  amount as paid, the name as resolved. Both, because the key is what you group by.
- **A repeat is not a first time.** Emit the "was this new" bit as its own parameter; a grant that
  re-pays a currency and unlocks nothing is a different row from a first unlock, and no amount of
  later querying separates them.
- **Pin the SEMANTICS next to the schema, not just the types.** A parameter whose meaning flips with
  a config toggle (a "streak" that counts consecutive days under one flag and total claims under the
  other) is worse than a renamed one: the column keeps its name, the type still validates, and every
  chart silently changes what it measures. Write down which toggle governs it.
- **Renaming a parameter is free ONLY before the first row exists** - i.e. while the schema has not
  been created yet. Audit names then, because after that a rename splits the metric.
- **A dropped field on the response is a dropped question.** Anything the server already returns and
  the event does not carry is a question that cannot be asked later, at zero cost to ask now.

## PAYLOAD-BUILT-BEFORE-THE-WRITE  - a success response reports the state its write created

A handler that reads once, derives its state flags (`alreadyDone`, `canAct`, `index`) and closes one
`payload(extra)` builder over them sends every flag at its PRE-write value unless the success branch
overrides it. The refusal branches are right by construction; the success branch is the only one
whose world changed, so it is the only one that can lie.

- **Override every flag the write changes, not just the obvious ones.** Observed: a daily-claim
  success response overrode `granted` and `canClaim` but not `claimedToday`, so the client drew today
  as unclaimed beside a CLAIMED button - and reopening the panel re-rendered the same cached response.
- **It reads as a missing UI subscription.** The view's handler did fire and faithfully rendered a
  stale payload. Only the next status read corrected it, so "restart fixes it, reopen does not" is
  the tell - read the payload before adding an event.
- **Verify by rendering the success response itself** through the view's own rule, and diff its state
  flags against a status read taken right after. A test that re-reads state instead passes while the
  bug ships.


## ONE-TIME-GRANT-READS-THE-RUN-NOT-THE-BEST

- "Only act on a new best" hides grants. When the grant check sits behind "this run beat the stored best", a stored
  best without its marker (a hand edit, a catalog that changed its target, a crash between two writes) blocks the
  unlock forever. Symptom: a target was met and nothing unlocked. Evaluate each one-time grant against the run's own
  value, write its positive marker once, and gate only the best-value write on improvement.
- A stored marker counts only while the catalog still pairs that challenge id with the thing it grants, so a renamed
  or re-pointed entry cannot unlock something else.

## LOCAL-PROGRESS-FILE-FAILS-CLOSED

- When progress must live in a local file (an offline slice, or before its server version exists): carry a schema
  version; write to a temp file and swap with `File.Replace`; a missing file is a fresh profile; an unreadable file is
  moved aside once to `<name>.unreadable-<utc>.json` with one warning, and a fresh profile starts; if that move fails,
  write nothing for the session (status Unavailable) rather than overwrite what could not be read.
- One class is the file's only caller, so the server version replaces one class. Credit progress from the gameplay
  edge (the level reached while playing), not from a results screen that quit-to-menu and an app kill both skip.
- A local file is client-writable. It holds bankable value only under an explicit developer override of the server-owned
  rule, and the positive-marker rule (ABSENCE-IS-NOT-PERMISSION) still applies.
