Tier 3 · Live services
Unity Gaming Services ops
Auth, Cloud Code, Cloud Save, Lobby/Relay and environments in production.
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). Checkgrape/torpedo/analysis/japan/raccoonbefore 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:
- 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.
- CLI:
ugs player list|get|disable|enable|delete(imperative, not file-deploy). - 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 —SignInWithServiceAccountthen other-player Cloud Save + Economy currency/inventory. This is how you grant/reset gold and items from the Editor. Gate withUNITY_EDITOR. Never shipENABLE_RUNTIME_ADMIN_APIS/ENABLE_RUNTIME_TRUSTED_APISin 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:
- Last-known HUD paint so the menu can draw before hydrate returns, and
- 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
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:
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: build and run exactly this mandated flow, and confirm with the user before executing it.
Order, while the old session is still signed in:
- Block if lobby/match is live — do not auto-dissolve other players. Toast "leave first".
- Clear persisted rejoin before sign-out (the leave still needs the old PlayerId).
- Wipe Cloud Save (default + protected) and own leaderboard row (Cloud Code).
AnalyticsService.RequestDataDeletion— Unity retries offline.- SIWA: revoke the Apple refresh token before step 6 (hook until the provider lands).
AuthenticationService.DeleteAccountAsyncfor anonymous too — guest Cloud Save / Economy / player name is still personal data. CTL/a mobile card game skip guests; that leaves orphans.SignOut(true)+ClearSessionToken(DeleteAccount already signs out; do both).- Wipe local account prefs only (wallet/energy/cosmetics/name). Keep device GameSettings.
- 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:
- It ships opted-OUT.
AnalyticsService.Instance.StartDataCollectionis mandatory — until it is called, everyRecordEventis 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). - A schema must exist in the dashboard or the event is discarded server-side, silently — the
SDK's own
RecordEventdocs 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. AnalyticsService.InstanceTHROWSServicesInitializationExceptionwhen 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.AddthrowsArgumentExceptionfor 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.Nameisinternal, andRecordEventclears 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
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
CONFIGobject 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
keepBestleaderboard 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:matchesPlayedclimbing 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 LoadAsyncs 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
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-sideAmountis a client-authored number. - Version-gate a SERVER behaviour change on a client-declared capability flag + a
CONFIGswitch. When a backend rule change would make a shipped client render a lie (an older build drawingcanClaim: falseas 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
grantedandcanClaimbut notclaimedToday, 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>.jsonwith 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.