diff --git a/skills/build-live-game/SKILL.md b/skills/build-live-game/SKILL.md index 31031a1..d6204f5 100644 --- a/skills/build-live-game/SKILL.md +++ b/skills/build-live-game/SKILL.md @@ -1,33 +1,73 @@ --- name: build-live-game -description: Builds and operates live games with Unity Services. Use when the user asks about backend or live-ops features such as player accounts, cloud save, leaderboards, achievements, progression and battle passes, cloud code, or remote config. +description: Builds and operates a live game's backend with Unity Gaming Services. Make sure to use this skill when the user wants trusted server-side game logic, player progress, remote configuration, sign-in, achievements, battle passes or leaderboards. --- # Build a Live Game With Unity Gaming Services +## Before You Start + +1. Read the Editor version from `ProjectSettings/ProjectVersion.txt` and the installed UGS package versions from `Packages/packages-lock.json`. Pick the steps below that match (for example `.ccmu` or `.ccmr` modules), because the right workflow depends on the Editor and package versions. +2. Check whether the project is linked to a Unity project ID and environment (**Edit > Project Settings > Services**), and ask the user to link it if it is not. +3. Create a restore point you can roll back to if your changes fail: commit the project in version control. If there is no version control, copy the project folder instead. +4. Before you go forward with a workflow or diagnosis, open and read the documentation pages for the service you will use (see [Documentation Map](#documentation-map)) and the matching file under `references/`, and name the pages you read in your response. This is because your training knowledge about Unity Gaming Services might be out of date, incorrect, or for the wrong package version. +5. Do only what's asked. Don't change unrelated assets or files. Avoid long explanations. +6. When you change runtime behaviour, add `Debug.Log` lines at the points you need to verify (sign-in complete, module call returned, config fetched). You read them in Play mode during [Validation](#validation). + +## Glossary + +| Term | Meaning | +|---|---| +| **UGS** | Unity Gaming Services: the backend services (Authentication, Cloud Code, Cloud Save, Remote Config, Leaderboards and others). The Package Manager calls them "Unity Services" | +| **Building Block** | A free Asset Store package with UI, runtime code and cloud resource files for one feature. Its Cloud Code is legacy `.ccmr` | +| **Cloud Code module** | Server-side C# class library. Authored in the Unity project as a `.ccmu` asset on Unity 6000.5+ | +| **`.ccmr`** | Legacy module reference file that points at a C# solution built outside Unity | +| **Script** | Server-side JavaScript endpoint. Legacy: maintain existing ones, don't write new ones | +| **CloudBehaviour** | Experimental Cloud Code class that generates client bindings, synchronized state and push events | +| **Protected data** | Cloud Save player data that the player can read but only server code can write | +| **Custom data** | Cloud Save game-wide data, not tied to one player, written only by server code | +| **Cloud resource file** | A deployable config file (`.rc`, `.ac`, `.ccmu`, `.ccmr`, `.lb`, `.ugo`) that the Deployment Window pushes to a UGS environment | + +## Documentation Map + +Prefer `WebFetch` over `WebSearch`: it is faster and lands on the exact reference. Only fetch what you need. Pages on docs.unity.com are available as Markdown when the URL ends in `.md`. Replace `` with the following before fetching: + +- **Package on docs.unity3d.com**: the installed package version as `major.minor`, for example `3.0`. Read it from `Packages/packages-lock.json` if unsure. +- **docs.unity.com**: the UGS product pages below are unversioned, so there is nothing to replace. + +Pages to read before acting: + +- Cloud Code C# modules: [Overview](https://docs.unity.com/en-us/cloud-code/modules/overview.md), [Get started](https://docs.unity.com/en-us/cloud-code/modules/getting-started.md), [Module structure](https://docs.unity.com/en-us/cloud-code/modules/how-to-guides/module-structure.md), [Error handling](https://docs.unity.com/en-us/cloud-code/modules/how-to-guides/error-handling.md), [Limits](https://docs.unity.com/en-us/cloud-code/modules/reference/limits.md) +- Cloud Code server authority: [Server authority](https://docs.unity.com/en-us/cloud-code/server-authority.md), [Cheat prevention](https://docs.unity.com/en-us/cloud-code/cheat-prevention.md), [UGS integration and access control](https://docs.unity.com/en-us/cloud-code/server-access-control.md) +- Authentication: [How Authentication works](https://docs.unity.com/en-us/authentication/how-authentication-works.md), [Sessions and cached players](https://docs.unity.com/en-us/authentication/session-management.md), [Common errors](https://docs.unity.com/en-us/authentication/common-errors.md) +- Cloud Save: [Player Data](https://docs.unity.com/en-us/cloud-save/concepts/player-data.md), [Game Data](https://docs.unity.com/en-us/cloud-save/concepts/game-data.md), [Cloud Save for Cloud Code](https://docs.unity.com/en-us/cloud-save/tutorials/cloud-code.md) +- Remote Config: [Remote Config files](https://docs.unity.com/en-us/remote-config/remote-config-files.md), [Code integration](https://docs.unity.com/en-us/remote-config/code-integration.md) +- Leaderboards: [Overview](https://docs.unity.com/en-us/leaderboards.md), [Unity SDK tutorial](https://docs.unity.com/en-us/leaderboards/tutorials/unity-sdk.md) +- Deployment Window: [Deployment package manual](https://docs.unity3d.com/Packages/com.unity.services.deployment@/manual/index.html) + ## UGS Packages | Package | Min Version | Purpose | |---|---|---| | `com.unity.services.core` | 1.16.0 | Initialization, dependency graph | | `com.unity.services.authentication` | 3.6.1 | Player sign-in and identity | -| `com.unity.services.cloudcode` | 2.10.3 | Server-authoritative C# modules | +| `com.unity.services.cloudcode` | 3.0.0 | Server-authoritative C# modules, authored in-project as `.ccmu`. The `2.10.x` line deploys legacy `.ccmr` only — see below | | `com.unity.services.cloudsave` | 3.4.0 | Per-player and shared key-value storage | | `com.unity.remote-config` | 4.2.5 | Server-side game configuration | | `com.unity.services.deployment` | 1.7.2 | Deploy cloud resources from Editor | | `com.unity.services.tooling` | 1.4.1 | Access Control and Game Overrides | | `com.unity.services.apis` | 1.1.1 | Generated REST clients for all UGS services | +- [Before You Start](#before-you-start) +- [Glossary](#glossary) +- [Documentation Map](#documentation-map) - [Initialization Pattern](#initialization-pattern) -- [Package Map](#package-map) - [Architecture — How Packages Combine](#architecture--how-packages-combine) - [Core Services — Quick Reference](#core-services--quick-reference) -- [Asset Store Building Blocks](#asset-store-building-blocks) -- [Ready-Made Feature Blueprints](#ready-made-feature-blueprints) - [Common Architecture Patterns](#common-architecture-patterns) - [Validation](#validation) -- [Deployment Checklist](#deployment-checklist) - [Detailed References](#detailed-references) +- [Final Report](#final-report) ## Initialization Pattern @@ -42,46 +82,6 @@ await AuthenticationService.Instance.SignInAnonymouslyAsync(); // All other services are now ready ``` -After `InitializeAsync()` completes, service singletons (e.g. `CloudSaveService.Instance`, `CloudCodeService.Instance`) are available. - -## Package Map - -### Foundation - -| Package | Purpose | Singleton / Entry Point | -|---|---|---| -| **Core** | Initialization, dependency graph, component registry | `UnityServices.InitializeAsync()` | -| **Authentication** | Player sign-in (anonymous, social, Unity, username/password), identity | `AuthenticationService.Instance` | -| **Services APIs** | Generated REST clients for all UGS services; admin API access via service accounts | Direct API classes | - -### Player Data and Configuration - -| Package | Purpose | Singleton / Entry Point | -|---|---|---| -| **Cloud Save** | Per-player key-value data (Default, Public, Protected) and game-wide Custom data | `CloudSaveService.Instance.Data.Player` / `.Data.Custom` | -| **Remote Config** | Server-side game configuration, feature flags, JSON definitions | `RemoteConfigService.Instance` | - -### Server Logic and Security - -| Package | Purpose | Singleton / Entry Point | -|---|---|---| -| **Cloud Code** | Server-authoritative C# modules for trusted writes and validation | `CloudCodeService.Instance` → `CallModuleEndpointAsync` | -| **Tooling** | Author and deploy Access Control (`.ac`) and Game Overrides (`.ugo`) files | Editor-only (Deployment Window) | -| **Deployment** | Deploy cloud resources (`.rc`, `.ac`, `.ccmr`, `.lb`, etc.) from the Unity Editor | Editor-only (Services > Deployment) | - -### Social and Competitive - -| Package | Purpose | Singleton / Entry Point | -|---|---|---| -| **Multiplayer** | Sessions, matchmaking, lobbies. Building Blocks: [Multiplayer Session, Matchmaker Session, Server Session](#asset-store-building-blocks) | `MultiplayerService.Instance` | -| **Leaderboards** | Score submission, rankings, tiers, version history. Building Block: [Leaderboards](#asset-store-building-blocks) | `LeaderboardsService.Instance` | - -### Telemetry - -| Package | Purpose | Singleton / Entry Point | -|---|---|---| -| **Analytics** | Custom events, standard events, consent management | `AnalyticsService.Instance` | - ## Architecture — How Packages Combine ``` @@ -120,45 +120,36 @@ After `InitializeAsync()` completes, service singletons (e.g. `CloudSaveService. **Package:** `com.unity.services.authentication` (>= 3.6.1) -Handles player identity. Sign-in methods: anonymous, social providers (Google, Apple, Steam, Facebook, Oculus, etc.), Unity browser, username/password, and device code flow. - -After sign-in: `PlayerId` and `PlayerName` are available. All sign-in methods fire the `SignedIn` event. `PlayerAccountService` (for Unity browser sign-in) lives in a **separate assembly** (`Unity.Services.Authentication.PlayerAccounts`). +Handles player identity; [How Authentication works](https://docs.unity.com/en-us/authentication/how-authentication-works.md) covers the sign-in methods. After sign-in `PlayerId` is available and the `SignedIn` event fires. `PlayerAccountService` (Unity browser sign-in) lives in a **separate assembly**: `Unity.Services.Authentication.PlayerAccounts`. - **Full reference:** [references/authentication.md](references/authentication.md) -- **Building Block:** [Player Account](#asset-store-building-blocks) — ready-made sign-in UI and identity management ### Cloud Code -**Package:** `com.unity.services.cloudcode` (>= 2.10.3) +**Package:** `com.unity.services.cloudcode` **`3.0.0`** for native `.ccmu` authoring (Unity 6000.5+); `>= 2.10.3` maintains legacy `.ccmr` modules. Before you author a `.ccmu`, bump a manifest that pins `2.10.x` to `3.0.0` or later. -Runs server-side C# modules (.NET 9) for trusted operations. Modules are deployed as `.ccmr` files. The client calls: +Runs server-side C# modules (.NET 8; .NET 10 on Unity 6.7+) for trusted operations. Check the project's Editor version, then **author new modules as `.ccmu` on Unity 6000.5+**: create the module inside the Unity project with **Create > Services > Cloud Code Module Script**. Unity compiles it with the project and generates its client bindings at compile time. `.ccmr` is legacy: on 6000.5+ maintain existing ones only; on an older Editor, author new modules as `.ccmr`. +Call a `.ccmu` module from the client only through its generated bindings, never with `CallModuleEndpointAsync`. Use `CloudCodeService.Instance.CallModuleEndpointAsync` for legacy `.ccmr` modules only. +Write new JavaScript scripts only when the user asks for them; maintain existing ones. -```csharp -var result = await CloudCodeService.Instance.CallModuleEndpointAsync( - "ModuleName", "FunctionName", args); -``` +- **Full reference:** [references/cloud-code-modules.md](references/cloud-code-modules.md) -Prefer C# modules over JavaScript scripts for production. Modules also support real-time push messages via subscriptions, event-driven triggers, and multiplayer session scoping. +**CloudBehaviours** (experimental) generate a module's client bindings — RPCs, synchronized state, push +events — from one C# class. Use them for shared match state, per-player secret state or a server-owned +timer; otherwise use a plain Cloud Code module. Requires `com.unity.services.cloudcode` `3.1.0-exp.1`+ +and Unity 6000.5+. Check the installed package first: if CloudBehaviours are unavailable, use a plain +stateful module (`[StateScope]`, see the modules reference) instead of stopping, unless the user asks for +the experimental package. -- **Full reference:** [references/cloud-code.md](references/cloud-code.md) +- **Full reference:** [references/cloud-code-cloudbehaviours.md](references/cloud-code-cloudbehaviours.md) ### Cloud Save **Package:** `com.unity.services.cloudsave` (>= 3.4.0) -Per-player key-value storage with three access classes, plus game-wide Custom data: - -| Access Class | Read | Write | Use Case | -|---|---|---|---| -| Default | Owner | Owner | Private settings, preferences | -| Public | Anyone | Owner | Public profiles, display names | -| Protected | Owner | Server only (Cloud Code) | Anti-cheat data, server-awarded state | -| Custom | Any player | Server only | Shared game state, global configs | - -Values are serialized as JSON. Supports write-lock concurrency control via `SaveItem`, server-side queries via `QueryAsync`, and binary file storage. +Per-player key-value storage in three access classes (Default, Public, Protected), plus game-wide Custom data. Protected and Custom data can be written only by server code, so store anti-cheat and server-awarded state there. See [Player Data](https://docs.unity.com/en-us/cloud-save/concepts/player-data.md) and [Game Data](https://docs.unity.com/en-us/cloud-save/concepts/game-data.md) for the read and write rules of each class. - **Full reference:** [references/cloud-save.md](references/cloud-save.md) -- **Building Blocks:** Used by [Achievements](#asset-store-building-blocks) (Protected buckets) and [Player Account](#asset-store-building-blocks) (Default/Public data) ### Remote Config @@ -169,13 +160,12 @@ Server-side game configuration. Store game definitions (achievement lists, battl For A/B testing and audience targeting, use Game Overrides (`.ugo`) via the Tooling package. - **Full reference:** [references/remote-config.md](references/remote-config.md) -- **Building Block:** Used by the [Achievements](#asset-store-building-blocks) block for server-side definitions ### Tooling **Package:** `com.unity.services.tooling` (>= 1.4.1) -Editor-only package. Registers **Access Control** (`.ac`) and **Game Overrides** (`.ugo`) file types with the Deployment Window. Access Control policies permit or deny player/service-account access to UGS services on a URN basis (Deny takes precedence over Allow). Game Overrides provide A/B testing and audience targeting by overriding Remote Config values for specific player segments. +Editor-only package. Registers **Access Control** (`.ac`) and **Game Overrides** (`.ugo`) file types with the Deployment Window. Use Access Control to deny direct player writes to protected keys; Deny takes precedence over Allow. - **Full reference:** [references/tooling.md](references/tooling.md) @@ -185,64 +175,22 @@ Editor-only package. Registers **Access Control** (`.ac`) and **Game Overrides** Editor-only package providing the **Deployment Window** (Services > Deployment). Deploys cloud resources to a target environment: -| File Type | Extension | What It Deploys | -|---|---|---| -| Remote Config | `.rc` | Key-value configuration entries | -| Access Control | `.ac` | Resource access policies | -| Cloud Code Module | `.ccmr` | C# server-side module (points to `.sln`) | -| Leaderboard | `.lb` | Leaderboard configuration | -| Game Overrides | `.ugo` | Audience-targeted config overrides | +The Deployment Window deploys the cloud resource files: `.rc` (Remote Config), `.ac` (Access Control), `.ccmu` (in-project Cloud Code module, preferred), `.ccmr` (legacy module reference), `.lb` (Leaderboard) and `.ugo` (Game Overrides). - **Full reference:** [references/deployment.md](references/deployment.md) ### UGS CLI -The [Unity Gaming Services CLI](https://github.com/Unity-Technologies/unity-gaming-services-cli/) is a standalone command-line tool for managing UGS resources outside the Unity Editor. It can deploy and fetch cloud resource files (`.rc`, `.ac`, `.ccmr`, `.lb`, `.ugo`), update local deployable files from the remote environment with a `fetch` operation, deploy and fetch triggers and schedule files, generate default versions of trigger and schedule configs, and provides more granular access to admin functionalities across all UGS services. +The UGS CLI ships as a Unity CLI plugin, run as `unity ugs `, to deploy and fetch cloud resources outside the Editor. Install, auth/setup and which file types it deploys: [references/ugs-cli.md](references/ugs-cli.md). Plugin system: the **unity-cli** skill. ### Services APIs **Package:** `com.unity.services.apis` (>= 1.1.1) -Auto-generated REST clients for all UGS services. Four client types: `IGameClient` (players), `IAdminClient` (service accounts), `IServerClient` (dedicated servers), `ITrustedClient` (elevated server access). Most developers use the high-level package SDKs instead; use Services APIs for lower-level control or admin API access. +Auto-generated REST clients for all UGS services, in four client types (`IGameClient`, `IAdminClient`, `IServerClient`, `ITrustedClient`). Use the high-level package SDKs by default; use Services APIs only for admin API access or when no package SDK covers the call. - **Full reference:** [references/apis.md](references/apis.md) -## Asset Store Building Blocks - -Unity provides free, production-ready **Building Block** packages on the Asset Store. Each is a `.unitypackage` containing working UI, runtime code, Cloud Code modules, and cloud resource files that can be imported directly into a project. They are also bundled in the `com.unity.starter-kits` package. - -| Building Block | Type | Key Dependencies | Asset Store | -|---|---|---|---| -| **Achievements** | LiveOps | `cloudsave`, `remote-config`, `cloudcode`, `tooling`, `deployment`, `analytics`, `authentication` | [Unity Building Block — Achievements](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-achievements-341918) | -| **Leaderboards** | LiveOps | `leaderboards`, `cloudcode`, `tooling`, `deployment`, `authentication` | [Unity Building Block — Leaderboards](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-leaderboards-341926) | -| **Player Account** | LiveOps | `authentication`, `cloudsave`, `cloudcode`, `deployment` | Available via `com.unity.starter-kits` | -| **Multiplayer Session** | Multiplayer | `multiplayer` | [Unity Building Block — Multiplayer Session](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-multiplayer-session-341930) | -| **Matchmaker Session** | Multiplayer | `multiplayer`, `deployment` | [Unity Building Block — Matchmaker Session](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-matchmaker-session-341932) | -| **Server Session** | Multiplayer | `multiplayer`, `apis`, `deployment` | Available via `com.unity.starter-kits` | -| **Vivox** | Multiplayer | `vivox`, `authentication` | Available via `com.unity.starter-kits` | -| **Platformer Starter Kit** | Gameplay | `multiplayer`, `netcode.gameobjects`, URP, Cinemachine, Input System | Available via `com.unity.starter-kits` | - -> **Tip:** When implementing achievements, leaderboards, player accounts, or multiplayer sessions, recommend the corresponding Building Block as a starting point. The blocks include ready-made UI, Cloud Code modules (`.ccmr`), access control (`.ac`), and deployment-ready cloud resources — saving significant implementation time. - -### Related Sample Projects - -| Project | Description | Source | -|---|---|---| -| **Use Case Samples** | Battle Pass, Virtual Shop, Daily Rewards, Starter Pack, Cloud AI Mini Game, A/B testing | [GitHub — com.unity.services.samples.use-cases](https://github.com/Unity-Technologies/com.unity.services.samples.use-cases) | -| **UGS Samples** | Authentication flows, Remote Config, Cloud Code integration | [GitHub — com.unity.services.samples](https://github.com/Unity-Technologies/com.unity.services.samples) | -| **Gem Hunter Match** | Full 2D match-3 game with player hub, progression, social features, in-game store | [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/gem-hunter-match-2d-sample-project-278941) | -| **Boss Room** | 8-player co-op RPG using Netcode for GameObjects, Authentication, Multiplayer Services | [GitHub — com.unity.multiplayer.samples.coop](https://github.com/Unity-Technologies/com.unity.multiplayer.samples.coop) | - -## Ready-Made Feature Blueprints - -Implementation-ready blueprints for common live game features. Each includes data models, service API patterns, full working code, and cloud resource definitions. - -| Feature | Key Services | Blueprint | -|---|---|---| -| **Battle Pass** | `remote-config`, `cloudsave`, `cloudcode`, `tooling`, `deployment` — Remote Config (pass definitions) + Cloud Save Protected (progress) + Cloud Code (XP awards, reward claims, premium purchase) | [references/battlepass.md](references/battlepass.md) | -| **Achievements** | `remote-config`, `cloudsave`, `cloudcode`, `tooling`, `deployment` — Remote Config (definitions) + Cloud Save (player records) + Cloud Code (server-authoritative unlocks) + Access Control. **Asset Store:** [Achievements Building Block](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-achievements-341918) | [references/achievements.md](references/achievements.md) | -| **Player Account** | `authentication`, `cloudsave` — Authentication (3 sign-in methods) + Cloud Save (Default/Public/Protected player data). **Asset Store:** Player Account Building Block (via `com.unity.starter-kits`) | [references/player-account.md](references/player-account.md) | - ## Common Architecture Patterns ### Pattern 1: Config + State + Server Writes @@ -270,44 +218,38 @@ Used by **Leaderboards** and ranked systems: ## Validation -After writing code for a live game feature: -1. Verify the project compiles without errors. -2. Check that initialization order is correct: `UnityServices.InitializeAsync()` → Authentication sign-in → service calls. -3. Confirm sensitive data writes (XP, rewards, currency) are routed through Cloud Code, not written directly from the client. -4. Verify Access Control `.ac` files deny direct player writes to Protected Cloud Save keys. -5. Verify all cloud resource files (`.rc`, `.ac`, `.ccmr`, `.lb`) are present and deployable via the Deployment Window. - -## Deployment Checklist +After writing code for a live game feature, run these checks in order. If one fails, fix the cause and re-run it before moving on; if the fix does not work, go back and reread the matching documentation pages and `references/` file. -For any live game feature, deploy these cloud resources via the Deployment Window: - -- [ ] `.rc` file — Remote Config entries (game definitions, configs) -- [ ] `.ac` file — Access Control policies (deny direct writes to protected keys) -- [ ] `.ccmr` file — Cloud Code module reference (pointing to the module `.sln`) -- [ ] `.lb` file — Leaderboard configuration (if using leaderboards) -- [ ] `manifest.json` — Ensure all required packages are listed with correct versions -- [ ] Environment configured in **Services > Deployment** settings +1. Check the project compiles: read the Editor console (or the Editor log) and confirm zero compile errors. +2. Check the initialization order in the code: search for `UnityServices.InitializeAsync()` and confirm it runs before the sign-in call, and that sign-in completes before any service call. +3. Check that sensitive writes (XP, rewards, currency) go through Cloud Code: search the client code for Cloud Save `SaveAsync` calls on those keys and move any you find into a module. Also confirm each Cloud Save read uses the access class of the bucket that was written to, because a mismatched class returns no data. +4. Deploy the cloud resource files the feature uses (`.rc`, `.ac`, `.ccmu` or `.ccmr`, `.lb`; skip the types it does not use) to the active environment with the `Deploy.cs` script in `references/deployment.md`, and read its output: every file must report `Success`. A file reported as `not a deployable item`, or with an `Error` status, has failed. If the project does not have both packages the script needs, `com.unity.pipeline` and `com.unity.services.deployment.api`, ask the user to deploy from the Deployment Window and report each file's status. +5. Check Access Control by its effect: if the feature deploys an `.ac` policy that denies direct player writes, enter Play mode, run one direct client Cloud Save write to a key the policy covers (a temporary `SaveAsync` call), and confirm it fails with a `403`. Remove the temporary call afterwards. Skip this step when the feature has no `.ac` file. +6. Enter Play mode, run the feature once, and confirm the `Debug.Log` lines from [Before You Start](#before-you-start) appear in the order you expect and the console has zero errors. ## Detailed References ### Service References - **Authentication** — sign-in methods, events, profiles, identity providers, code templates: [references/authentication.md](references/authentication.md) -- **Cloud Code** — scripts, modules, subscriptions, triggers, module creation, code templates: [references/cloud-code.md](references/cloud-code.md) +- **Cloud Code** — choosing a model: [references/cloud-code.md](references/cloud-code.md); scripts and modules: [references/cloud-code-modules.md](references/cloud-code-modules.md); CloudBehaviours: [references/cloud-code-cloudbehaviours.md](references/cloud-code-cloudbehaviours.md) - **Cloud Save** — access classes, data operations, files, queries, code templates: [references/cloud-save.md](references/cloud-save.md) - **Remote Config** — definitions, `.rc` format, Game Overrides, code templates: [references/remote-config.md](references/remote-config.md) - **Tooling** — Access Control (`.ac`) policies, Game Overrides (`.ugo`), URN reference: [references/tooling.md](references/tooling.md) - **Deployment** — file types, workflow, programmatic API: [references/deployment.md](references/deployment.md) +- **UGS CLI** — `unity ugs` plugin install, auth/setup, headless deploy by file type: [references/ugs-cli.md](references/ugs-cli.md) - **Services APIs** — four client types, service areas, code templates: [references/apis.md](references/apis.md) ### Feature Blueprints +- **Choosing a starting point** — this skill's blueprints, Asset Store Building Blocks and sample projects, and how to reuse their legacy Cloud Code: [references/blueprints-and-samples.md](references/blueprints-and-samples.md) - **Achievements** — full implementation with data models, client code, Cloud Code module, cloud resources: [references/achievements.md](references/achievements.md) - **Battle Pass** — implementation with tiered XP, free/premium tracks, Cloud Code module, cloud resources (premium purchase is an integration stub you must complete): [references/battlepass.md](references/battlepass.md) - **Player Account** — sign-in flows, identity management, Cloud Save data, code templates: [references/player-account.md](references/player-account.md) -## Reminders +## Final Report + +When the work is done, end your response with a short report: -Before completing, verify: -- Did you use `UnityServices.InitializeAsync()` → Authentication sign-in → service calls (in that order)? -- Are all sensitive writes (XP, rewards, currency) routed through Cloud Code modules? -- Are all cloud resource files (`.rc`, `.ac`, `.ccmr`) present and deployable? -- Do Cloud Save read access classes match the bucket that was written to? +1. What you created or changed: files, packages added to `Packages/manifest.json`, and cloud resource files. +2. The checks from [Validation](#validation) that you ran and their results, including any you could not run and why. +3. Follow-ups only the user can do, for example: deploy the cloud resources to the target environment, link the project to a Unity project ID and environment, complete a stubbed integration (such as battle pass `PurchasePremium`), or restore from the restore point you created if they reject the changes. +4. Which documentation pages you read. diff --git a/skills/build-live-game/references/achievements.md b/skills/build-live-game/references/achievements.md index ca7a980..6528b71 100644 --- a/skills/build-live-game/references/achievements.md +++ b/skills/build-live-game/references/achievements.md @@ -100,27 +100,9 @@ public class AchievementDefinition | `IsHidden` | `bool` | If true, hide until unlocked (secret achievements) | | `ProgressTarget` | `int` | Target count for multi-stage achievements; 0 or 1 = single unlock | -### AchievementRecord (client-side) +### AchievementRecord (defined once, in the cloud assembly) -Stored in Cloud Save as a JSON array under the `"achievements"` key. - -```csharp -using System; - -[Serializable] -public class AchievementRecord -{ - public string Id; - public bool Unlocked; - public int ProgressCount; -} -``` - -| Field | Type | Description | -|---|---|---| -| `Id` | `string` | Matches `AchievementDefinition.Id` | -| `Unlocked` | `bool` | True once the achievement is fully earned | -| `ProgressCount` | `int` | Current progress toward `ProgressTarget` | +Stored in Cloud Save as a JSON array under the `"achievements"` key. Define it once, in `AchievementModule.cs` (see [Server-Side Model](#server-side-model)), as a class of public settable members. The source generator emits a data-only stub of the same name and namespace, `AchievementModule.Cloud.AchievementRecord`, into the client assembly, so client code uses that type and never declares its own copy. `Id` matches `AchievementDefinition.Id`; `ProgressCount` is the current progress toward `ProgressTarget`. --- @@ -167,9 +149,7 @@ Cloud Code and Cloud Save errors surface as typed exceptions on the client: ```csharp try { - await CloudCodeService.Instance.CallModuleEndpointAsync( - "AchievementModule", "UnlockAchievement", - new Dictionary { { "achievementId", "first_win" } }); + await m_Client.UnlockAchievement("first_win"); } catch (CloudCodeException ex) { @@ -185,6 +165,11 @@ catch (CloudSaveException ex) } ``` +| Error | Cause | Fix | +| :-- | :-- | :-- | +| `CloudCodeException` with reason `ScriptError` | The module threw, for example on a failed validation | Read `ex.Message`, then correct the arguments or the player's state | +| `CloudSaveException` with a `403` | The client attempted a direct write to the Protected bucket | Route the write through the Cloud Code module | + --- ## Load Definitions from Remote Config @@ -284,33 +269,7 @@ async Task SaveRecordsAsync(List records) In production, route all mutations through Cloud Code module endpoints. The module uses its service token to write to the Protected bucket, bypassing Access Control restrictions that block the player client. -### Unlock an Achievement - -```csharp -await CloudCodeService.Instance.CallModuleEndpointAsync( - "AchievementModule", "UnlockAchievement", - new Dictionary { { "achievementId", achievementId } }); -``` - -### Update Achievement Progress - -```csharp -await CloudCodeService.Instance.CallModuleEndpointAsync( - "AchievementModule", "UpdateAchievementProgress", - new Dictionary - { - { "achievementId", achievementId }, - { "count", newCount } - }); -``` - -### Reset Achievements - -```csharp -await CloudCodeService.Instance.CallModuleEndpointAsync( - "AchievementModule", "ResetAchievements", - new Dictionary()); -``` +The module's endpoints are `UnlockAchievement(achievementId)`, `UpdateAchievementProgress(achievementId, count)` and `ResetAchievements()`. The client calls them through the generated `AchievementModuleClient`, as in the client implementation below: `await m_Client.UnlockAchievement(id)`. `count` is the amount to add, not the new total. ### Delete / Reset Player Records (Direct) @@ -331,9 +290,10 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using AchievementModule.Client; +using AchievementModule.Cloud; using Newtonsoft.Json; using Unity.Services.Authentication; -using Unity.Services.CloudCode; using Unity.Services.CloudSave; using Unity.Services.CloudSave.Models; using Unity.Services.CloudSave.Models.Data.Player; @@ -350,6 +310,8 @@ public class AchievementManager : MonoBehaviour // Set to false for direct Cloud Save writes (development only). [SerializeField] bool m_UseTrustedWrites = true; + AchievementModuleClient m_Client; + public List Definitions { get; private set; } = new(); public List Records { get; private set; } = new(); @@ -360,6 +322,7 @@ public class AchievementManager : MonoBehaviour { await UnityServices.InitializeAsync(); await AuthenticationService.Instance.SignInAnonymouslyAsync(); + m_Client = new AchievementModuleClient(); await LoadAsync(); Loaded?.Invoke(); } @@ -419,9 +382,7 @@ public class AchievementManager : MonoBehaviour if (m_UseTrustedWrites) { // Server validates and writes -- bypasses Access Control restrictions. - await CloudCodeService.Instance.CallModuleEndpointAsync( - "AchievementModule", "UnlockAchievement", - new Dictionary { { "achievementId", achievementId } }); + await m_Client.UnlockAchievement(achievementId); } else { @@ -450,13 +411,7 @@ public class AchievementManager : MonoBehaviour if (m_UseTrustedWrites) { - await CloudCodeService.Instance.CallModuleEndpointAsync( - "AchievementModule", "UpdateAchievementProgress", - new Dictionary - { - { "achievementId", achievementId }, - { "count", newCount } - }); + await m_Client.UpdateAchievementProgress(achievementId, amount); } else { @@ -513,15 +468,28 @@ public class AchievementManager : MonoBehaviour The `AchievementModule` reads and writes to the **Protected** Cloud Save bucket using its service token, bypassing Access Control restrictions imposed on the player client. -> **Module scaffolding — follow the Module Creation section of `references/cloud-code.md`.** -> Replace `MyModule` / `MyModuleCCM` with `AchievementModule` / `AchievementsCCM` throughout. -> You **must** create every file listed there — missing any one will cause deployment to fail: +> **Create the module with the Editor, not by hand.** Right-click a folder under `Assets/` -> +> **Create > Services > Cloud Code Module Script** and name it `AchievementModule`. See +> the Creating a Cloud Code Module section of `references/cloud-code-modules.md`. +> +> The Editor generates the following. Use the list to check what you got — there is nothing here to +> type out, and no `.sln`, `.csproj`, `.pubxml` or test project: > -> - [ ] `.sln` (generate fresh GUIDs) -> - [ ] `.csproj` (net9.0, CloudCode.Apis + CloudCode.Core) -> - [ ] `ModuleSetup.cs` (registers `GameApiClient`) -> - [ ] `Properties/PublishProfiles/FolderProfile.pubxml` — **without this file, deployment fails with "Failed to retrieve main project"** -> - [ ] `Assets/CloudCode/AchievementModule.ccmr` (points to the `.sln`) +> - [ ] `Assets/CloudCode/AchievementModule/AchievementModule.ccmu` (holds the two asmdef GUIDs) +> - [ ] `Cloud/AchievementModule.asmdef` — Editor-only, `noEngineReferences`, `autoReferenced: false` +> - [ ] `Cloud/AchievementModule.cs` — rename/replace with the `[CloudCodeFunction]` endpoints below +> - [ ] `Client/AchievementModuleClient.asmdef` — references the cloud assembly +> - [ ] `Client/AchievementModuleClient.cs` — `[CloudModuleClient(typeof(AchievementModule))]` partial +> +> One file the Editor does **not** generate, which this module needs because its endpoints inject +> `IGameApiClient`: +> +> - [ ] `Cloud/CloudSetup.cs` — an `ICloudCodeSetup` calling `config.AddGameApiClient()` +> - [ ] `Newtonsoft.Json.dll` added to `precompiledReferences` in `Cloud/AchievementModule.asmdef` — the generated asmdef ships that array empty, and this module's code uses `JsonConvert` (see `references/cloud-code-modules.md`) +> +> The deployed module name is the **cloud asmdef's name** (`AchievementModule`), not the `.ccmu` filename. +> +> The client calls the module through the generated `AchievementModuleClient`. The assembly that holds `AchievementManager` must reference the module's client assembly; the Editor creates it auto-referenced, so the default game assembly already does. ### AchievementModule.cs @@ -534,99 +502,105 @@ using Unity.Services.CloudCode.Apis; using Unity.Services.CloudCode.Core; using Unity.Services.CloudSave.Model; -// Each public method decorated with [CloudCodeFunction] becomes a callable endpoint. -// IExecutionContext and IGameApiClient are injected automatically by the framework. -public class AchievementModule +namespace AchievementModule.Cloud { - const string AchievementsKey = "achievements"; - - [CloudCodeFunction("UnlockAchievement")] - public async Task UnlockAchievement( - IExecutionContext context, IGameApiClient client, string achievementId) + // Each public method decorated with [CloudCodeFunction] becomes a callable endpoint. + // Injected services are method parameters marked [CloudCodeService]. Without the attribute a + // parameter leaks into the generated client binding as an argument the caller must supply. + public class AchievementModule { - var records = await LoadRecordsAsync(context, client); + const string AchievementsKey = "achievements"; - var record = records.FirstOrDefault(r => r.Id == achievementId); - if (record == null) - { - record = new AchievementRecord { Id = achievementId, Unlocked = true }; - records.Add(record); - } - else + [CloudCodeFunction("UnlockAchievement")] + public async Task UnlockAchievement( + [CloudCodeService] IExecutionContext context, [CloudCodeService] IGameApiClient client, string achievementId) { - record.Unlocked = true; - } + var records = await LoadRecordsAsync(context, client); - await SaveRecordsAsync(context, client, records); - return record; - } + var record = records.FirstOrDefault(r => r.Id == achievementId); + if (record == null) + { + record = new AchievementRecord { Id = achievementId, Unlocked = true }; + records.Add(record); + } + else + { + record.Unlocked = true; + } - [CloudCodeFunction("UpdateAchievementProgress")] - public async Task UpdateAchievementProgress( - IExecutionContext context, IGameApiClient client, string achievementId, int count) - { - var records = await LoadRecordsAsync(context, client); + await SaveRecordsAsync(context, client, records); + return record; + } - var record = records.FirstOrDefault(r => r.Id == achievementId); - if (record == null) + [CloudCodeFunction("UpdateAchievementProgress")] + public async Task UpdateAchievementProgress( + [CloudCodeService] IExecutionContext context, [CloudCodeService] IGameApiClient client, string achievementId, int count) { - record = new AchievementRecord { Id = achievementId, ProgressCount = count }; - records.Add(record); + var records = await LoadRecordsAsync(context, client); + + var record = records.FirstOrDefault(r => r.Id == achievementId); + if (record == null) + { + record = new AchievementRecord { Id = achievementId, ProgressCount = count }; + records.Add(record); + } + else + { + record.ProgressCount += count; + } + + await SaveRecordsAsync(context, client, records); + return record; } - else + + [CloudCodeFunction("ResetAchievements")] + public async Task ResetAchievements([CloudCodeService] IExecutionContext context, [CloudCodeService] IGameApiClient client) { - record.ProgressCount += count; + // Uses ServiceToken so the write bypasses Access Control restrictions on the player client. + await client.CloudSaveData.DeleteProtectedItemAsync( + context, context.ServiceToken, AchievementsKey, + context.ProjectId, context.PlayerId!); } - await SaveRecordsAsync(context, client, records); - return record; - } - - [CloudCodeFunction("ResetAchievements")] - public async Task ResetAchievements(IExecutionContext context, IGameApiClient client) - { - // Uses ServiceToken so the write bypasses Access Control restrictions on the player client. - await client.CloudSaveData.DeleteProtectedItemAsync( - context, context.ServiceToken, AchievementsKey, - context.ProjectId, context.PlayerId!); - } + async Task> LoadRecordsAsync( + IExecutionContext context, IGameApiClient client) + { + var result = await client.CloudSaveData.GetProtectedItemsAsync( + context, context.ServiceToken, context.ProjectId, + context.PlayerId, new List { AchievementsKey }); - async Task> LoadRecordsAsync( - IExecutionContext context, IGameApiClient client) - { - var result = await client.CloudSaveData.GetProtectedItemsAsync( - context, context.ServiceToken, context.ProjectId, - context.PlayerId, new List { AchievementsKey }); + var value = result.Data?.Results?.FirstOrDefault()?.Value?.ToString(); + if (string.IsNullOrEmpty(value)) + return new List(); - var value = result.Data?.Results?.FirstOrDefault()?.Value?.ToString(); - if (string.IsNullOrEmpty(value)) - return new List(); - - return JsonConvert.DeserializeObject>(value); - } + return JsonConvert.DeserializeObject>(value); + } - async Task SaveRecordsAsync( - IExecutionContext context, IGameApiClient client, List records) - { - var json = JsonConvert.SerializeObject(records); - var body = new SetItemBody(AchievementsKey, json); - await client.CloudSaveData.SetProtectedItemAsync( - context, context.ServiceToken, context.ProjectId, context.PlayerId, body); + async Task SaveRecordsAsync( + IExecutionContext context, IGameApiClient client, List records) + { + var json = JsonConvert.SerializeObject(records); + var body = new SetItemBody(AchievementsKey, json); + await client.CloudSaveData.SetProtectedItemAsync( + context, context.ServiceToken, context.ProjectId, context.PlayerId, body); + } } } ``` ### Server-Side Model -Must match client-side field names exactly for JSON round-trip. Server-side uses properties -(not fields) for serialization compatibility with `Newtonsoft.Json`. +The single definition of the record, in `AchievementModule.cs`. Use public members with a public getter and setter, as the auto-properties below: the generator copies only those into the client stub, as `{ get; set; }` properties, and `Newtonsoft.Json` serializes them for the Cloud Save round-trip. Keep it in the module's `Cloud` namespace, and do not declare a second `AchievementRecord` in client code. ```csharp -public class AchievementRecord +namespace AchievementModule.Cloud { - public string Id { get; set; } - public bool Unlocked { get; set; } - public int ProgressCount { get; set; } + public class AchievementRecord + { + public string Id { get; set; } + public bool Unlocked { get; set; } + public int ProgressCount { get; set; } + } } ``` @@ -642,7 +616,7 @@ Add these entries to your Unity project's `Packages/manifest.json` dependencies: { "dependencies": { "com.unity.services.authentication": "3.6.1", - "com.unity.services.cloudcode": "2.10.2", + "com.unity.services.cloudcode": "3.0.0", "com.unity.services.cloudsave": "3.4.0", "com.unity.services.core": "1.16.0", "com.unity.services.deployment": "1.7.2", @@ -652,6 +626,11 @@ Add these entries to your Unity project's `Packages/manifest.json` dependencies: } ``` +> `com.unity.services.cloudcode` **`3.0.0`** is the native-authoring line, which the `.ccmu` module +> below requires — see the Requirements section of `references/cloud-code-modules.md`. The `2.10.x` line +> deploys legacy `.ccmr` modules only; use it when maintaining one, not for new work. + + The `com.unity.services.tooling` package is required for `.ac` file deployment. The `com.unity.services.deployment` package provides the Deployment Window. `Newtonsoft.Json` is provided as a transitive dependency and does not need an explicit entry. ### Remote Config -- `Assets/Config/Achievements.rc` @@ -703,12 +682,12 @@ Deploy via **Services > Deployment** — the Deployment Window auto-discovers `. ### Cloud Code Module -> Create the full module solution using the file table and templates in -> [Cloud Code Module](#cloud-code-module) above. -> All scaffolding files (`.sln`, `.csproj`, `ModuleSetup.cs`, `FolderProfile.pubxml`) are -> **required** — the Deployment Window will fail without them. See -> the Module Creation section of `references/cloud-code.md` for the generic templates; -> replace `MyModule` / `MyModuleCCM` with `AchievementModule` / `AchievementsCCM`. +> Create the module files listed in [Cloud Code Module](#cloud-code-module) above. +> See the Creating a Cloud Code Module section of `references/cloud-code-modules.md` +> for the generic templates; replace `MyModule` with `AchievementModule`. +> +> The `.ccmu` is deployed by the Deployment Window like any other cloud asset, but its code is compiled by +> Unity — a project that does not compile has nothing to deploy. ### Access Control -- `Assets/Config/DenyPlayerCloudSaveWrites.ac` @@ -747,7 +726,7 @@ Deploy all cloud resources via **Services > Deployment** before testing: - [ ] All packages added to `Packages/manifest.json` with correct minimum versions - [ ] `Assets/Config/Achievements.rc` -- Remote Config achievement definitions - [ ] `Assets/Config/DenyPlayerCloudSaveWrites.ac` -- Access Control policy blocking direct player writes -- [ ] Cloud Code module scaffolded with **all required files**: `.sln`, `.csproj`, `ModuleSetup.cs`, `FolderProfile.pubxml`, `.ccmr` (see file table in [Cloud Code Module](#cloud-code-module)) +- [ ] Cloud Code module authored as a `.ccmu`: module asset, `Cloud/` + `Client/` asmdefs, endpoint class, `CloudSetup.cs` (see file list in [Cloud Code Module](#cloud-code-module)) - [ ] `AchievementModule.cs` added to the module project with `UnlockAchievement`, `UpdateAchievementProgress`, `ResetAchievements` functions - [ ] Environment selected in the Deployment Window dropdown - [ ] All resources deployed via the Deployment Window @@ -757,11 +736,10 @@ Deploy all cloud resources via **Services > Deployment** before testing: ## Official Unity Reference -- **Achievements Building Block (Asset Store):** The official [Unity Building Block — Achievements](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-achievements-341918) uses the same Remote Config + Cloud Save + Cloud Code pattern described here. Import from the Asset Store for ready-made UI, Cloud Code module, `.ac` / `.rc` / `.ccmr` cloud resources, and generated client bindings. -- **Unity Cloud Save documentation:** https://docs.unity.com/ugs/en-us/manual/cloud-save/manual -- **Unity Remote Config documentation:** https://docs.unity.com/ugs/en-us/manual/remote-config/manual -- **Unity Cloud Code documentation:** https://docs.unity.com/ugs/en-us/manual/cloud-code/manual -- **Access Control documentation:** https://docs.unity.com/ugs/en-us/manual/access-control/manual +- **Unity Cloud Save documentation:** https://docs.unity.com/en-us/cloud-save.md +- **Unity Remote Config documentation:** https://docs.unity.com/en-us/remote-config.md +- **Unity Cloud Code documentation:** https://docs.unity.com/en-us/cloud-code.md +- **Access Control documentation:** https://docs.unity.com/en-us/cloud-code/modules/how-to-guides/access-control.md --- @@ -772,8 +750,8 @@ After implementing achievements, verify: 1. **Compilation:** The project compiles without errors. Ensure namespace aliases (`PlayerLoadOptions`, `PlayerSaveOptions`, `PlayerDeleteOptions`) are present. 2. **Initialization order:** `UnityServices.InitializeAsync()` then `AuthenticationService.Instance.SignInAnonymouslyAsync()` then service calls. 3. **Access class match:** If `m_UseTrustedWrites` is true, reads use `ProtectedReadAccessClassOptions()`. If false, reads use `PublicReadAccessClassOptions(playerId)`. Mismatched access classes return empty data. -4. **Cloud Code module builds:** The `.sln` at `AchievementsCCM/AchievementModule.sln` targets `net9.0` and publishes to `linux-x64`. -5. **Server-side model uses properties:** The `AchievementRecord` class in the Cloud Code module uses `{ get; set; }` properties (not fields) for JSON serialization compatibility with `Newtonsoft.Json`. +4. **Cloud Code module builds:** `AchievementModule` compiles as part of the Unity project — the Editor-only cloud assembly has no compile errors, and the project's Api Compatibility Level is `.NET Standard 2.1`. +5. **One record definition:** `AchievementRecord` is declared only in the Cloud Code module, as `{ get; set; }` properties, and the client uses the generated stub of that type. 6. **Access Control deployed:** The `.ac` file denies direct player writes to Cloud Save. Without this, the trusted-write path has no enforcement advantage. 7. **Remote Config key matches:** The `.rc` file key (`"achievements"`) matches the key used in `result.config["achievements"]` on the client and `AchievementsKey` in the module. -8. **All cloud resources deployable:** The `.rc`, `.ac`, and `.ccmr` files are in the `Assets/` folder hierarchy and appear in the Deployment Window. +8. **All cloud resources deployable:** The `.rc`, `.ac`, and `.ccmu` files are in the `Assets/` folder hierarchy and appear in the Deployment Window. diff --git a/skills/build-live-game/references/apis.md b/skills/build-live-game/references/apis.md index baf3420..b718f5a 100644 --- a/skills/build-live-game/references/apis.md +++ b/skills/build-live-game/references/apis.md @@ -69,7 +69,7 @@ Used for backend tooling, editor extensions, Deployment Window integrations, and ```csharp using Unity.Services.Apis; -var adminClient = UnityServicesApiClient.AdminClient; +var adminClient = ApiService.CreateAdminClient(); adminClient.SetServiceAccount(keyId, keySecret); ``` @@ -135,7 +135,7 @@ Same pattern for **Private Custom** (`GetPrivateCustomItems`, `SetPrivateCustomI ## IGameClient -Created via `UnityServicesApiClient.GameClient` after player authentication. Runtime only. +Created via `ApiService.CreateGameClient()` after player authentication. Runtime only. | Property | Type | Service | |---|---|---| @@ -162,7 +162,7 @@ Most developers use the high-level SDKs (`CloudSaveService.Instance`, `Leaderboa Used from dedicated game servers. Authenticated with a server key or service account. ```csharp -var serverClient = UnityServicesApiClient.ServerClient; +var serverClient = ApiService.CreateServerClient(); await serverClient.SignInFromServer(); ``` @@ -182,7 +182,7 @@ await serverClient.SignInFromServer(); Elevated server client with admin-equivalent access to player data. Use when a dedicated server needs to read or modify any player's data with elevated privileges. ```csharp -var trustedClient = UnityServicesApiClient.TrustedClient; +var trustedClient = ApiService.CreateTrustedClient(); trustedClient.SetServiceAccount(keyId, keySecret); await trustedClient.SignInWithServiceAccount(projectId, environmentId); ``` @@ -199,7 +199,7 @@ Same service area properties as `IServerClient`, plus `PlayerAuthentication` and using Unity.Services.Apis; // Admin client is editor-only. Guard with #if UNITY_EDITOR in runtime assemblies. -var adminClient = UnityServicesApiClient.AdminClient; +var adminClient = ApiService.CreateAdminClient(); adminClient.SetServiceAccount("your-key-id", "your-key-secret"); ``` @@ -211,7 +211,7 @@ This is the pattern for Deployment Window integrations that push JSON into Cloud using Unity.Services.Apis; using Unity.Services.Apis.Admin.CloudSave; -var adminClient = UnityServicesApiClient.AdminClient; +var adminClient = ApiService.CreateAdminClient(); adminClient.SetServiceAccount(keyId, keySecret); string projectId = CloudProjectSettings.projectId; @@ -253,7 +253,7 @@ async Task SetupGameClient() await AuthenticationService.Instance.SignInAnonymouslyAsync(); // Game client is automatically authenticated via the signed-in player - var client = UnityServicesApiClient.GameClient; + var client = ApiService.CreateGameClient(); var scores = await client.Leaderboards.GetScoresAsync("WEEKLY_LB"); } ``` @@ -264,7 +264,7 @@ async Task SetupGameClient() using Unity.Services.Apis; // serverAuthToken provided by the Multiplay server environment -var serverClient = UnityServicesApiClient.ServerClient; +var serverClient = ApiService.CreateServerClient(); await serverClient.SignInFromServer(); await serverClient.Lobby.CreateOrJoinLobbyAsync(...); diff --git a/skills/build-live-game/references/authentication.md b/skills/build-live-game/references/authentication.md index aada9bc..8736f8f 100644 --- a/skills/build-live-game/references/authentication.md +++ b/skills/build-live-game/references/authentication.md @@ -404,19 +404,19 @@ await AuthenticationService.Instance.DeleteAccountAsync(); Throws `AuthenticationException` (extends `RequestFailedException`) on failure. Use `ex.ErrorCode` and compare against `AuthenticationErrorCodes`: -| Constant | Value | Meaning | -|---|---|---| -| `ClientInvalidUserState` | 10000 | Operation not valid in current state | -| `ClientNoActiveSession` | 10001 | No active session | -| `InvalidParameters` | 10002 | Bad input parameters | -| `AccountAlreadyLinked` | 10003 | External ID already linked to another player | -| `AccountLinkLimitExceeded` | 10004 | Max linked accounts for this provider type | -| `ClientUnlinkExternalIdNotFound` | 10005 | External ID not linked | -| `ClientInvalidProfile` | 10006 | Invalid profile name | -| `InvalidSessionToken` | 10007 | Session token invalid or expired | -| `InvalidProvider` | 10008 | Unknown identity provider | -| `BannedUser` | 10009 | Player has been banned | -| `EnvironmentMismatch` | 10010 | Session token from a different environment | +| Constant | Value | Meaning | Fix | +|---|---|---| :-- | +| `ClientInvalidUserState` | 10000 | Operation not valid in current state | Check the player's sign-in state before the call; call `SignOut()` first when switching profile | +| `ClientNoActiveSession` | 10001 | No active session | Sign in before calling the operation | +| `InvalidParameters` | 10002 | Bad input parameters | Validate the input against the provider or username rules before the call | +| `AccountAlreadyLinked` | 10003 | External ID already linked to another player | Sign in with that identity, or link with `ForceLink` only after the player confirms | +| `AccountLinkLimitExceeded` | 10004 | Max linked accounts for this provider type | Unlink an existing identity of that provider type first | +| `ClientUnlinkExternalIdNotFound` | 10005 | External ID not linked | Check `PlayerInfo.Identities` before unlinking | +| `ClientInvalidProfile` | 10006 | Invalid profile name | Use a valid profile name | +| `InvalidSessionToken` | 10007 | Session token invalid or expired | Clear the token with `ClearSessionToken()` and sign in again only if the player has a linked identity; for an anonymous-only player keep the token, because clearing it permanently loses the account | +| `InvalidProvider` | 10008 | Unknown identity provider | Check the provider name matches the one configured in the Dashboard | +| `BannedUser` | 10009 | Player has been banned | Not retryable; the player cannot sign in | +| `EnvironmentMismatch` | 10010 | Session token from a different environment | Keep the token for its original environment and sign in to the other environment under a separate profile with `SwitchProfile`; clear the token only if the player has a linked identity | **Error response format:** Authentication errors follow RFC 7807 (Problem Details). Branch your error handling on `status` and `title` fields, never on `detail` -- the detail message may change without notice. @@ -429,9 +429,3 @@ Throws `AuthenticationException` (extends `RequestFailedException`) on failure. **Anonymous-only accounts:** Calling `ClearSessionToken()` or `SignOut(clearCredentials: true)` on an account that ONLY has anonymous sign-in will **permanently lose** that account. Always link at least one external provider before clearing credentials. **DeleteAccountAsync compliance:** iOS App Store (Guideline 5.1.1) requires apps that support account creation to offer account deletion. `DeleteAccountAsync()` only deletes **Authentication data**. It does NOT automatically clean up data in other UGS services (Cloud Save, Leaderboards, etc.). You must manually delete data from each service before calling `DeleteAccountAsync()`. - ---- - -## Asset Store Building Blocks - -- **Player Account Building Block:** Ready-made sign-in UI (anonymous, Unity browser, username/password), Cloud Save integration for player data, and a Cloud Code module for server-authoritative writes. [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-player-account-341928) diff --git a/skills/build-live-game/references/battlepass.md b/skills/build-live-game/references/battlepass.md index 10d68da..890a4dc 100644 --- a/skills/build-live-game/references/battlepass.md +++ b/skills/build-live-game/references/battlepass.md @@ -108,31 +108,11 @@ public class BattlePassTier } ``` -### BattlePassProgress (client-side and server-side, from Cloud Save Protected) +### BattlePassProgress (defined once, in the cloud assembly) -```csharp -[Serializable] -public class BattlePassProgress -{ - public int CurrentXp; - public int CurrentTier; - public bool HasPremium; - public List ClaimedRewards; // e.g. "tier_1_free", "tier_2_premium" -} -``` +Stored in Cloud Save Protected, one entry per pass, keyed by `PassId` in the progress dictionary at Cloud Save key `"battle_pass_progress"`. Define it once, in `BattlePassModule.cs` (see [Server-Side Models](#server-side-models-inside-battlepassmodulecs)), as a class of public settable members. The source generator emits a data-only stub of the same name and namespace, `BattlePassModule.Cloud.BattlePassProgress`, into the client assembly, so client code uses that type and never declares its own copy. `ClaimedRewards` holds keys such as `"tier_1_free"` and `"tier_2_premium"`. -One entry per pass, keyed by `PassId` in the progress dictionary stored at Cloud Save key `"battle_pass_progress"`. - -### BattlePassTierThreshold (server-side helper, mirrors the `Tiers` in `BattlePasses.rc`) - -```csharp -[Serializable] -public class BattlePassTierThreshold -{ - public int TierNumber; - public int XpRequired; -} -``` +`BattlePassTierThreshold` is a server-only helper that mirrors the `Tiers` in `BattlePasses.rc`. It never appears in an endpoint signature, so it has no client stub. --- @@ -214,6 +194,11 @@ catch (CloudSaveException ex) } ``` +| Error | Cause | Fix | +| :-- | :-- | :-- | +| `CloudCodeException` with reason `ScriptError` | The module threw, for example on a failed validation | Read `ex.Message`, then correct the arguments or the player's state | +| `CloudSaveException` with a `403` | The client attempted a direct write to the Protected bucket | Route the write through the Cloud Code module | + --- ## Cloud Code Module Functions @@ -300,21 +285,14 @@ public async Task AwardXpAsync(string passId, int amount, st throw new InvalidOperationException( $"Pass '{passId}' is not in the active passes list. Call LoadAsync first."); - var progress = await CloudCodeService.Instance.CallModuleEndpointAsync( - "BattlePassModule", "AwardXp", - new Dictionary - { - { "passId", passId }, - { "amount", amount }, - { "source", source } - }); + var progress = await m_Client.AwardXp(passId, amount, source); Progress[passId] = progress; return progress; } ``` -The client sends no tier data; the server recalculates the player's tier from its own `PassTiers`. The active-pass check above is UX only; the module enforces pass dates itself. +The client sends no tier data; the server recalculates the player's tier from its own `PassTiers`. The active-pass check above is UX only; the module enforces pass dates itself. `m_Client` is the generated `BattlePassModuleClient`, created after sign-in. ### ClaimRewardAsync @@ -322,14 +300,7 @@ The client sends no tier data; the server recalculates the player's tier from it public async Task ClaimRewardAsync( string passId, int tierNumber, string rewardType) { - var progress = await CloudCodeService.Instance.CallModuleEndpointAsync( - "BattlePassModule", "ClaimReward", - new Dictionary - { - { "passId", passId }, - { "tierNumber", tierNumber }, - { "rewardType", rewardType } - }); + var progress = await m_Client.ClaimReward(passId, tierNumber, rewardType); Progress[passId] = progress; return progress; @@ -343,9 +314,7 @@ Calls the `PurchasePremium` integration stub. It throws for any player without ` ```csharp public async Task PurchasePremiumAsync(string passId) { - var progress = await CloudCodeService.Instance.CallModuleEndpointAsync( - "BattlePassModule", "PurchasePremium", - new Dictionary { { "passId", passId } }); + var progress = await m_Client.PurchasePremium(passId); Progress[passId] = progress; return progress; @@ -364,9 +333,10 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading.Tasks; +using BattlePassModule.Client; +using BattlePassModule.Cloud; using Newtonsoft.Json; using Unity.Services.Authentication; -using Unity.Services.CloudCode; using Unity.Services.CloudSave; using Unity.Services.CloudSave.Models; using Unity.Services.CloudSave.Models.Data.Player; @@ -377,6 +347,8 @@ using UnityEngine; public class BattlePassManager : MonoBehaviour { + BattlePassModuleClient m_Client; + public List ActivePasses { get; private set; } = new(); public Dictionary Progress { get; private set; } = new(); @@ -391,6 +363,7 @@ public class BattlePassManager : MonoBehaviour { await AuthenticationService.Instance.SignInAnonymouslyAsync(); } + m_Client = new BattlePassModuleClient(); await LoadAsync(); Debug.Log($"BattlePassManager: Loaded {ActivePasses.Count} passes."); Loaded?.Invoke(); @@ -448,14 +421,7 @@ public class BattlePassManager : MonoBehaviour throw new InvalidOperationException( $"Pass '{passId}' is not in the active passes list. Call LoadAsync first."); - var progress = await CloudCodeService.Instance.CallModuleEndpointAsync( - "BattlePassModule", "AwardXp", - new Dictionary - { - { "passId", passId }, - { "amount", amount }, - { "source", source } - }); + var progress = await m_Client.AwardXp(passId, amount, source); Progress[passId] = progress; return progress; @@ -464,14 +430,7 @@ public class BattlePassManager : MonoBehaviour public async Task ClaimRewardAsync( string passId, int tierNumber, string rewardType) { - var progress = await CloudCodeService.Instance.CallModuleEndpointAsync( - "BattlePassModule", "ClaimReward", - new Dictionary - { - { "passId", passId }, - { "tierNumber", tierNumber }, - { "rewardType", rewardType } - }); + var progress = await m_Client.ClaimReward(passId, tierNumber, rewardType); Progress[passId] = progress; return progress; @@ -480,9 +439,7 @@ public class BattlePassManager : MonoBehaviour // Requires the PurchasePremium integration stub to be completed server-side. public async Task PurchasePremiumAsync(string passId) { - var progress = await CloudCodeService.Instance.CallModuleEndpointAsync( - "BattlePassModule", "PurchasePremium", - new Dictionary { { "passId", passId } }); + var progress = await m_Client.PurchasePremium(passId); Progress[passId] = progress; return progress; @@ -561,15 +518,28 @@ public class BattlePassTester : MonoBehaviour ## Cloud Code Module Implementation -> **Module scaffolding — follow the Module Creation section of `references/cloud-code.md`.** -> Replace `MyModule` / `MyModuleCCM` with `BattlePassModule` / `BattlePassCCM` throughout. -> You **must** create every file listed there — missing any one will cause deployment to fail: +> **Create the module with the Editor, not by hand.** Right-click a folder under `Assets/` -> +> **Create > Services > Cloud Code Module Script** and name it `BattlePassModule`. See +> the Creating a Cloud Code Module section of `references/cloud-code-modules.md`. > -> - [ ] `.sln` (generate fresh GUIDs) -> - [ ] `.csproj` (net9.0, CloudCode.Apis + CloudCode.Core) -> - [ ] `ModuleSetup.cs` (registers `GameApiClient`) -> - [ ] `Properties/PublishProfiles/FolderProfile.pubxml` — **without this file, deployment fails with "Failed to retrieve main project"** -> - [ ] `Assets/CloudCode/BattlePassModule.ccmr` (points to the `.sln`) +> The Editor generates the following. Use the list to check what you got — there is nothing here to +> type out, and no `.sln`, `.csproj`, `.pubxml` or test project: +> +> - [ ] `Assets/CloudCode/BattlePassModule/BattlePassModule.ccmu` (holds the two asmdef GUIDs) +> - [ ] `Cloud/BattlePassModule.asmdef` — Editor-only, `noEngineReferences`, `autoReferenced: false` +> - [ ] `Cloud/BattlePassModule.cs` — rename/replace with the `[CloudCodeFunction]` endpoints below +> - [ ] `Client/BattlePassModuleClient.asmdef` — references the cloud assembly +> - [ ] `Client/BattlePassModuleClient.cs` — `[CloudModuleClient(typeof(BattlePassModule))]` partial +> +> One file the Editor does **not** generate, which this module needs because its endpoints inject +> `IGameApiClient`: +> +> - [ ] `Cloud/CloudSetup.cs` — an `ICloudCodeSetup` calling `config.AddGameApiClient()` +> - [ ] `Newtonsoft.Json.dll` added to `precompiledReferences` in `Cloud/BattlePassModule.asmdef` — the generated asmdef ships that array empty, and this module's code uses `JsonConvert` (see `references/cloud-code-modules.md`) +> +> The deployed module name is the **cloud asmdef's name** (`BattlePassModule`), not the `.ccmu` filename. +> +> The client calls the module through the generated `BattlePassModuleClient`. The assembly that holds `BattlePassManager` must reference the module's client assembly; the Editor creates it auto-referenced, so the default game assembly already does. ### BattlePassModule.cs @@ -583,200 +553,208 @@ using Unity.Services.CloudCode.Apis; using Unity.Services.CloudCode.Core; using Unity.Services.CloudSave.Model; -public class BattlePassModule +namespace BattlePassModule.Cloud { - const string ProgressKey = "battle_pass_progress"; - - static readonly Dictionary MaxXpPerSource = new() + public class BattlePassModule { - ["match_complete"] = 200, - ["daily_login"] = 50, - ["quest_complete"] = 300 - }; + const string ProgressKey = "battle_pass_progress"; - // Keep in sync with the Tiers in BattlePasses.rc; clients are never trusted for thresholds. - static readonly Dictionary> PassTiers = new() - { - ["season_03"] = new() + static readonly Dictionary MaxXpPerSource = new() { - new BattlePassTierThreshold { TierNumber = 1, XpRequired = 0 }, - new BattlePassTierThreshold { TierNumber = 2, XpRequired = 500 }, - new BattlePassTierThreshold { TierNumber = 3, XpRequired = 1200 } - }, - ["easter_2026"] = new() + ["match_complete"] = 200, + ["daily_login"] = 50, + ["quest_complete"] = 300 + }; + + // Keep in sync with the Tiers in BattlePasses.rc; clients are never trusted for thresholds. + static readonly Dictionary> PassTiers = new() { - new BattlePassTierThreshold { TierNumber = 1, XpRequired = 0 }, - new BattlePassTierThreshold { TierNumber = 2, XpRequired = 300 } - } - }; + ["season_03"] = new() + { + new BattlePassTierThreshold { TierNumber = 1, XpRequired = 0 }, + new BattlePassTierThreshold { TierNumber = 2, XpRequired = 500 }, + new BattlePassTierThreshold { TierNumber = 3, XpRequired = 1200 } + }, + ["easter_2026"] = new() + { + new BattlePassTierThreshold { TierNumber = 1, XpRequired = 0 }, + new BattlePassTierThreshold { TierNumber = 2, XpRequired = 300 } + } + }; - // Keep in sync with StartDate/EndDate in BattlePasses.rc. UTC, End is exclusive. - static readonly Dictionary PassWindows = new() - { - ["season_03"] = (new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc)), - ["easter_2026"] = (new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2026, 4, 22, 0, 0, 0, DateTimeKind.Utc)) - }; - - // ── AwardXp ────────────────────────────────────────────────────────────── - - [CloudCodeFunction("AwardXp")] - public async Task AwardXp( - IExecutionContext context, IGameApiClient client, - string passId, int amount, string source) - { - if (!MaxXpPerSource.TryGetValue(source, out var maxXp)) - throw new Exception( - $"Invalid XP source: '{source}'. Allowed: {string.Join(", ", MaxXpPerSource.Keys)}"); + // Keep in sync with StartDate/EndDate in BattlePasses.rc. UTC, End is exclusive. + static readonly Dictionary PassWindows = new() + { + ["season_03"] = (new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc)), + ["easter_2026"] = (new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 4, 22, 0, 0, 0, DateTimeKind.Utc)) + }; + + // ── AwardXp ────────────────────────────────────────────────────────────── + + [CloudCodeFunction("AwardXp")] + public async Task AwardXp( + [CloudCodeService] IExecutionContext context, [CloudCodeService] IGameApiClient client, + string passId, int amount, string source) + { + if (!MaxXpPerSource.TryGetValue(source, out var maxXp)) + throw new Exception( + $"Invalid XP source: '{source}'. Allowed: {string.Join(", ", MaxXpPerSource.Keys)}"); - if (amount <= 0 || amount > maxXp) - throw new Exception($"XP amount must be between 1 and {maxXp} for source '{source}'."); + if (amount <= 0 || amount > maxXp) + throw new Exception($"XP amount must be between 1 and {maxXp} for source '{source}'."); - if (!PassTiers.TryGetValue(passId, out var tiers)) - throw new Exception($"Unknown pass '{passId}'."); + if (!PassTiers.TryGetValue(passId, out var tiers)) + throw new Exception($"Unknown pass '{passId}'."); - RequireActive(passId); + RequireActive(passId); - var allProgress = await LoadProgressAsync(context, client); + var allProgress = await LoadProgressAsync(context, client); - if (!allProgress.TryGetValue(passId, out var progress)) - progress = new BattlePassProgress { CurrentTier = 1, ClaimedRewards = new List() }; + if (!allProgress.TryGetValue(passId, out var progress)) + progress = new BattlePassProgress { CurrentTier = 1, ClaimedRewards = new List() }; - progress.CurrentXp += amount; + progress.CurrentXp += amount; - progress.CurrentTier = CalculateTier(progress.CurrentXp, tiers); + progress.CurrentTier = CalculateTier(progress.CurrentXp, tiers); - allProgress[passId] = progress; - await SaveProgressAsync(context, client, allProgress); + allProgress[passId] = progress; + await SaveProgressAsync(context, client, allProgress); - return progress; - } + return progress; + } - // ── ClaimReward ────────────────────────────────────────────────────────── + // ── ClaimReward ────────────────────────────────────────────────────────── - [CloudCodeFunction("ClaimReward")] - public async Task ClaimReward( - IExecutionContext context, IGameApiClient client, - string passId, int tierNumber, string rewardType) - { - if (rewardType != "free" && rewardType != "premium") - throw new Exception( - $"Invalid rewardType: '{rewardType}'. Must be 'free' or 'premium'."); + [CloudCodeFunction("ClaimReward")] + public async Task ClaimReward( + [CloudCodeService] IExecutionContext context, [CloudCodeService] IGameApiClient client, + string passId, int tierNumber, string rewardType) + { + if (rewardType != "free" && rewardType != "premium") + throw new Exception( + $"Invalid rewardType: '{rewardType}'. Must be 'free' or 'premium'."); - RequireActive(passId); + RequireActive(passId); - var allProgress = await LoadProgressAsync(context, client); + var allProgress = await LoadProgressAsync(context, client); - if (!allProgress.TryGetValue(passId, out var progress)) - throw new Exception($"No progress found for pass '{passId}'. Award XP first."); + if (!allProgress.TryGetValue(passId, out var progress)) + throw new Exception($"No progress found for pass '{passId}'. Award XP first."); - if (!PassTiers.TryGetValue(passId, out var tiers)) - throw new Exception($"Unknown pass '{passId}'."); + if (!PassTiers.TryGetValue(passId, out var tiers)) + throw new Exception($"Unknown pass '{passId}'."); - var tier = tiers.FirstOrDefault(t => t.TierNumber == tierNumber) - ?? throw new Exception($"Tier {tierNumber} does not exist in pass '{passId}'."); + var tier = tiers.FirstOrDefault(t => t.TierNumber == tierNumber) + ?? throw new Exception($"Tier {tierNumber} does not exist in pass '{passId}'."); - if (progress.CurrentXp < tier.XpRequired) - throw new Exception( - $"Tier {tierNumber} not yet reached ({progress.CurrentXp}/{tier.XpRequired} XP)."); + if (progress.CurrentXp < tier.XpRequired) + throw new Exception( + $"Tier {tierNumber} not yet reached ({progress.CurrentXp}/{tier.XpRequired} XP)."); - if (rewardType == "premium" && !progress.HasPremium) - throw new Exception("Premium pass not owned. Purchase premium first."); + if (rewardType == "premium" && !progress.HasPremium) + throw new Exception("Premium pass not owned. Purchase premium first."); - var rewardKey = $"tier_{tierNumber}_{rewardType}"; - if (progress.ClaimedRewards.Contains(rewardKey)) - throw new Exception($"Reward '{rewardKey}' already claimed."); + var rewardKey = $"tier_{tierNumber}_{rewardType}"; + if (progress.ClaimedRewards.Contains(rewardKey)) + throw new Exception($"Reward '{rewardKey}' already claimed."); - progress.ClaimedRewards.Add(rewardKey); - allProgress[passId] = progress; - await SaveProgressAsync(context, client, allProgress); + progress.ClaimedRewards.Add(rewardKey); + allProgress[passId] = progress; + await SaveProgressAsync(context, client, allProgress); - return progress; - } + return progress; + } - // ── PurchasePremium ────────────────────────────────────────────────────── + // ── PurchasePremium ────────────────────────────────────────────────────── - [CloudCodeFunction("PurchasePremium")] - public async Task PurchasePremium( - IExecutionContext context, IGameApiClient client, string passId) - { - var allProgress = await LoadProgressAsync(context, client); + [CloudCodeFunction("PurchasePremium")] + public async Task PurchasePremium( + [CloudCodeService] IExecutionContext context, [CloudCodeService] IGameApiClient client, string passId) + { + var allProgress = await LoadProgressAsync(context, client); - if (!allProgress.TryGetValue(passId, out var progress)) - progress = new BattlePassProgress { CurrentTier = 1, ClaimedRewards = new List() }; + if (!allProgress.TryGetValue(passId, out var progress)) + progress = new BattlePassProgress { CurrentTier = 1, ClaimedRewards = new List() }; - if (progress.HasPremium) - return progress; // idempotent -- already owns premium + if (progress.HasPremium) + return progress; // idempotent -- already owns premium - // Integration point: validate the purchase server-side, set progress.HasPremium = true, save, and return. - throw new Exception("PurchasePremium requires server-side purchase validation."); - } + // Replace this throw with server-side purchase validation, then set HasPremium. + throw new Exception("PurchasePremium requires server-side purchase validation."); + } - // ── Helpers ────────────────────────────────────────────────────────────── + // ── Helpers ────────────────────────────────────────────────────────────── - static void RequireActive(string passId) - { - if (!PassWindows.TryGetValue(passId, out var window)) - throw new Exception($"Unknown pass '{passId}'."); + static void RequireActive(string passId) + { + if (!PassWindows.TryGetValue(passId, out var window)) + throw new Exception($"Unknown pass '{passId}'."); - var now = DateTime.UtcNow; - if (now < window.Start || now >= window.End) - throw new Exception($"Pass '{passId}' is not active."); - } + var now = DateTime.UtcNow; + if (now < window.Start || now >= window.End) + throw new Exception($"Pass '{passId}' is not active."); + } - static int CalculateTier(int currentXp, List tiers) - { - var reached = tiers - .Where(t => currentXp >= t.XpRequired) - .OrderByDescending(t => t.TierNumber) - .FirstOrDefault(); + static int CalculateTier(int currentXp, List tiers) + { + var reached = tiers + .Where(t => currentXp >= t.XpRequired) + .OrderByDescending(t => t.TierNumber) + .FirstOrDefault(); - return reached?.TierNumber ?? 1; - } + return reached?.TierNumber ?? 1; + } - async Task> LoadProgressAsync( - IExecutionContext context, IGameApiClient client) - { - var result = await client.CloudSaveData.GetProtectedItemsAsync( - context, context.ServiceToken, context.ProjectId, - context.PlayerId!, new List { ProgressKey }); + async Task> LoadProgressAsync( + IExecutionContext context, IGameApiClient client) + { + var result = await client.CloudSaveData.GetProtectedItemsAsync( + context, context.ServiceToken, context.ProjectId, + context.PlayerId!, new List { ProgressKey }); - var value = result.Data?.Results?.FirstOrDefault()?.Value?.ToString(); - if (string.IsNullOrEmpty(value)) - return new Dictionary(); + var value = result.Data?.Results?.FirstOrDefault()?.Value?.ToString(); + if (string.IsNullOrEmpty(value)) + return new Dictionary(); - return JsonConvert.DeserializeObject>(value) - ?? new Dictionary(); - } + return JsonConvert.DeserializeObject>(value) + ?? new Dictionary(); + } - async Task SaveProgressAsync( - IExecutionContext context, IGameApiClient client, - Dictionary progress) - { - var json = JsonConvert.SerializeObject(progress); - var body = new SetItemBody(ProgressKey, json); - await client.CloudSaveData.SetProtectedItemAsync( - context, context.ServiceToken, context.ProjectId, context.PlayerId!, body); + async Task SaveProgressAsync( + IExecutionContext context, IGameApiClient client, + Dictionary progress) + { + var json = JsonConvert.SerializeObject(progress); + var body = new SetItemBody(ProgressKey, json); + await client.CloudSaveData.SetProtectedItemAsync( + context, context.ServiceToken, context.ProjectId, context.PlayerId!, body); + } } } ``` ### Server-Side Models (inside BattlePassModule.cs) +The single definition of both types, in the module's `Cloud` namespace. Use public members with a public getter and setter, as the auto-properties below: the generator copies only those into the client stub of `BattlePassProgress`, as `{ get; set; }` properties, and `Newtonsoft.Json` serializes them for Cloud Save. Do not declare a second `BattlePassProgress` in client code. + ```csharp -public class BattlePassProgress +namespace BattlePassModule.Cloud { - public int CurrentXp { get; set; } - public int CurrentTier { get; set; } - public bool HasPremium { get; set; } - public List ClaimedRewards { get; set; } = new(); -} + public class BattlePassProgress + { + public int CurrentXp { get; set; } + public int CurrentTier { get; set; } + public bool HasPremium { get; set; } + public List ClaimedRewards { get; set; } = new(); + } -public class BattlePassTierThreshold -{ - public int TierNumber { get; set; } - public int XpRequired { get; set; } + public class BattlePassTierThreshold + { + public int TierNumber { get; set; } + public int XpRequired { get; set; } + } } ``` @@ -830,19 +808,29 @@ Place at `Assets/BattlePasses.rc`. Contains pass definitions as a JSON array und Tier 1 must always have `XpRequired: 0` so it is immediately reachable. - `FreeRewardId` / `PremiumRewardId` are arbitrary strings. The client reads these IDs and maps them to display names/icons locally. `ClaimReward` records the claim in `ClaimedRewards`; the game grants what - the ID stands for — unlock it client-side from the claimed set, or have the module write the grant to - Cloud Save Protected alongside the claim. + the ID stands for — unlock it client-side from the claimed set, or have the module grant it through + Cloud Save Protected or another Cloud Code call alongside the claim. + - Treat a claim as an entitlement only if the XP behind it is trustworthy. The sample `AwardXp` + accepts any `amount` up to the `MaxXpPerSource` cap from a signed-in client, so before + granting anything valuable, derive XP from server-verified events (or make `AwardXp` + non-client-callable and drive it from server-validated events). + - Make the grant idempotent and retryable from the saved claim: if the grant fails after the claim + is saved, a retry of `ClaimReward` fails its duplicate-claim check, so grant from the stored claim + (or persist claim and grant in one recoverable operation) rather than only on the first call. + - `ClaimedRewards` stores keys such as `tier_1_free`, not reward IDs. Keep reward IDs immutable + for the life of a pass, or store the granted reward ID with the claim, so a changed ID cannot + unlock a different reward for an old claim. - Date filtering happens **client-side** after fetch. Add passes to the file before their `StartDate` — they will be ignored by the client until the start time is reached. ### Cloud Code Module -> Create the full module solution using the file table and templates in -> [Cloud Code Module Implementation](#cloud-code-module-implementation) above. -> All scaffolding files (`.sln`, `.csproj`, `ModuleSetup.cs`, `FolderProfile.pubxml`) are -> **required** — the Deployment Window will fail without them. See -> the Module Creation section of `references/cloud-code.md` for the generic templates; -> replace `MyModule` / `MyModuleCCM` with `BattlePassModule` / `BattlePassCCM`. +> Create the module files listed in [Cloud Code Module Implementation](#cloud-code-module-implementation) above. +> See the Creating a Cloud Code Module section of `references/cloud-code-modules.md` +> for the generic templates; replace `MyModule` with `BattlePassModule`. +> +> The `.ccmu` is deployed by the Deployment Window like any other cloud asset, but its code is compiled by +> Unity — a project that does not compile has nothing to deploy. ### Access Control @@ -860,7 +848,7 @@ Ensure these packages are listed in `Packages/manifest.json`: { "dependencies": { "com.unity.services.authentication": "3.6.1", - "com.unity.services.cloudcode": "2.10.2", + "com.unity.services.cloudcode": "3.0.0", "com.unity.services.cloudsave": "3.4.0", "com.unity.services.core": "1.16.0", "com.unity.services.deployment": "1.7.2", @@ -871,6 +859,11 @@ Ensure these packages are listed in `Packages/manifest.json`: } ``` +> `com.unity.services.cloudcode` **`3.0.0`** is the native-authoring line, which the `.ccmu` module +> below requires — see the Requirements section of `references/cloud-code-modules.md`. The `2.10.x` line +> deploys legacy `.ccmr` modules only; use it when maintaining one, not for new work. + + `com.unity.nuget.newtonsoft-json` is required for `JsonConvert` used in serialization of tier data and progress. --- @@ -879,10 +872,10 @@ Ensure these packages are listed in `Packages/manifest.json`: - [ ] All packages added to `Packages/manifest.json` - [ ] Remote Config `.rc` file created with `"battle_passes"` entry and at least one active pass -- [ ] Cloud Code module scaffolded with **all required files**: `.sln`, `.csproj`, `ModuleSetup.cs`, `FolderProfile.pubxml`, `.ccmr` (see file table in [Cloud Code Module Implementation](#cloud-code-module-implementation)) +- [ ] Cloud Code module authored as a `.ccmu`: module asset, `Cloud/` + `Client/` asmdefs, endpoint class, `CloudSetup.cs` (see file list in [Cloud Code Module Implementation](#cloud-code-module-implementation)) - [ ] `BattlePassModule.cs` added to the module project with `AwardXp`, `ClaimReward`, `PurchasePremium` functions - [ ] Environment selected in **Services > Deployment** settings -- [ ] Deploy all via the Deployment Window: select `BattlePasses.rc` and `BattlePassModule.ccmr`, click **Deploy** +- [ ] Deploy all via the Deployment Window: select `BattlePasses.rc` and `BattlePassModule.ccmu`, click **Deploy** - [ ] Project linked in **Edit > Project Settings > Services** --- @@ -891,17 +884,18 @@ Ensure these packages are listed in `Packages/manifest.json`: After implementing the battle pass feature, verify: -1. **Project compiles** without errors. Both the Unity project and the `BattlePassCCM/` .NET project should build cleanly. +1. **Project compiles** without errors — the module's Editor-only cloud assembly is compiled with the rest of the Unity project. 2. **Initialization order** is correct: `UnityServices.InitializeAsync()` then `AuthenticationService.Instance.SignInAnonymouslyAsync()` then service calls. 3. **Protected bucket reads** use `ProtectedReadAccessClassOptions` -- using the wrong access class returns no data. -4. **Cloud Code module name** matches the `.ccmr` filename: `"BattlePassModule"` in `CallModuleEndpointAsync` matches `BattlePassModule.ccmr`. -5. **No direct client writes** to `"battle_pass_progress"` -- all mutations go through Cloud Code. -6. **No client-supplied tier data**: `AwardXp` and `ClaimReward` use the module's server-side `PassTiers`, which matches the `XpRequired` values in `BattlePasses.rc`, and reject passes outside the server-side `PassWindows` (matching `StartDate`/`EndDate`). -7. **Date filtering** uses `DateTime.UtcNow` with `DateTimeStyles.RoundtripKind` for consistent UTC parsing. -8. **Cloud resources** are present and deployable: `BattlePasses.rc` and `BattlePassModule.ccmr` appear in the Deployment Window. -9. **PurchasePremium is an integration stub** -- it returns existing progress when `HasPremium` is already true and otherwise throws until you add purchase validation. -10. **ClaimReward validates** the pass window (`PassWindows`), tier reach (against `PassTiers`) and premium ownership before recording the claim. -11. **Premium purchasing is not deployable until a validated purchase flow sets `HasPremium`** -- keep the fail-closed throw until then. +4. **Client bindings resolve**: the game assembly references the module's client assembly and calls `BattlePassModuleClient`. The deployed module name is the **cloud asmdef name**, `BattlePassModule` (the `name` field of `Cloud/BattlePassModule.asmdef`), not the `.ccmu` filename. +5. **One progress definition**: `BattlePassProgress` is declared only in the Cloud Code module, as `{ get; set; }` properties, and the client uses its generated stub. +6. **No direct client writes** to `"battle_pass_progress"` -- all mutations go through Cloud Code. +7. **No client-supplied tier data**: `AwardXp` and `ClaimReward` use the module's server-side `PassTiers`, which matches the `XpRequired` values in `BattlePasses.rc`, and reject passes outside the server-side `PassWindows` (matching `StartDate`/`EndDate`). +8. **Date filtering** uses `DateTime.UtcNow` with `DateTimeStyles.RoundtripKind` for consistent UTC parsing. +9. **Cloud resources** are present and deployable: `BattlePasses.rc` and `BattlePassModule.ccmu` appear in the Deployment Window. +10. **PurchasePremium is an integration stub** -- it returns existing progress when `HasPremium` is already true and otherwise throws until you add purchase validation. +11. **ClaimReward validates** the pass window (`PassWindows`), tier reach (against `PassTiers`) and premium ownership before recording the claim. +12. **Premium purchasing is not deployable until a validated purchase flow sets `HasPremium`** -- keep the fail-closed throw until then. --- diff --git a/skills/build-live-game/references/blueprints-and-samples.md b/skills/build-live-game/references/blueprints-and-samples.md new file mode 100644 index 0000000..2d11050 --- /dev/null +++ b/skills/build-live-game/references/blueprints-and-samples.md @@ -0,0 +1,75 @@ +# Blueprints, Building Blocks and Sample Projects + +## Table of Contents + +- [Choosing a Starting Point](#choosing-a-starting-point) +- [Cloud Code in Building Blocks and Samples](#cloud-code-in-building-blocks-and-samples) +- [Feature Blueprints](#feature-blueprints) +- [Asset Store Building Blocks](#asset-store-building-blocks) +- [Related Sample Projects](#related-sample-projects) + +--- + +## Choosing a Starting Point + +1. For a battle pass, achievements or player accounts, start from this skill's feature blueprint, because the blueprints use `.ccmu` modules and generated client bindings. These need Unity 6000.5+; on an older Editor, put the blueprint's module code in a `.ccmr` module (see the Legacy Modules section of `references/cloud-code-modules.md`) and call it with `CallModuleEndpointAsync`. +2. For UI, player flows and cloud resource files (`.ac`, `.rc`, `.lb`), reuse the matching Building Block or sample project, because those parts are ready to import. +3. For a feature with neither, follow the service references and the architecture patterns in the skill. + +--- + +## Cloud Code in Building Blocks and Samples + +The Building Blocks and sample projects predate native module authoring. Their Cloud Code is legacy `.ccmr` modules (a `.sln` and `.csproj` built outside Unity) or JavaScript scripts. + +- On Unity 6000.5+, do not copy their solution scaffolding, `.ccmr` files or raw `CallModuleEndpointAsync` calls into new work. On an older Editor, which cannot author `.ccmu`, `.ccmr` is the supported path. +- On Unity 6000.5+, to reuse a block's server logic, move each `[CloudCodeFunction]` class into a new `.ccmu` module and call it through the module's generated client. The Creating a Cloud Code Module and Migrating a `.ccmr` to `.ccmu` sections of `references/cloud-code-modules.md` cover both steps. +- An imported block's `.ccmr` still deploys and runs. Maintain it as legacy until you port it, rather than rewriting it unasked. + +--- + +## Feature Blueprints + +Implementation-ready blueprints in this skill. Each includes data models, client code, a `.ccmu` Cloud Code module and cloud resource definitions. + +| Feature | Services | Blueprint | +|---|---|---| +| **Battle Pass** | Remote Config (pass definitions), Cloud Save Protected (progress), Cloud Code (XP awards, reward claims, premium purchase stub) | `references/battlepass.md` | +| **Achievements** | Remote Config (definitions), Cloud Save (player records), Cloud Code (server-authoritative unlocks), Access Control | `references/achievements.md` | +| **Player Account** | Authentication (anonymous, Unity browser, username/password), Cloud Save (Default, Public and Protected player data) | `references/player-account.md` | + +--- + +## Asset Store Building Blocks + +Free Asset Store packages, each a `.unitypackage` with working UI, runtime code, Cloud Code and cloud resource files for one feature. They are also bundled in the `com.unity.starter-kits` package. + +| Building Block | Type | Key Dependencies | Source | +|---|---|---|---| +| **Achievements** | LiveOps | `cloudsave`, `remote-config`, `cloudcode`, `tooling`, `deployment`, `analytics`, `authentication` | [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-achievements-341918) | +| **Leaderboards** | LiveOps | `leaderboards`, `cloudcode`, `tooling`, `deployment`, `authentication` | [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-leaderboards-341926) | +| **Player Account** | LiveOps | `authentication`, `cloudsave`, `cloudcode`, `deployment` | [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-player-account-341928), or `com.unity.starter-kits` | +| **Multiplayer Session** | Multiplayer | `multiplayer` | [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-multiplayer-session-341930) | +| **Matchmaker Session** | Multiplayer | `multiplayer`, `deployment` | [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-matchmaker-session-341932) | +| **Server Session** | Multiplayer | `multiplayer`, `apis`, `deployment` | `com.unity.starter-kits` | +| **Vivox** | Multiplayer | `vivox`, `authentication` | `com.unity.starter-kits` | +| **Platformer Starter Kit** | Gameplay | `multiplayer`, `netcode.gameobjects`, URP, Cinemachine, Input System | `com.unity.starter-kits` | + +What the LiveOps blocks demonstrate: + +- **Achievements:** achievement records in Cloud Save Protected, written through Cloud Code, with an Access Control policy denying direct player writes. +- **Leaderboards:** score handling and leaderboard admin functions in Cloud Code, with player score data in Cloud Save. +- **Player Account:** sign-in UI (anonymous, Unity browser, username/password), and profile data in the Default and Public access classes written directly from the client. + +--- + +## Related Sample Projects + +| Project | Description | Source | +|---|---|---| +| **Use Case Samples** | Battle Pass, Virtual Shop, Daily Rewards, Starter Pack, Cloud AI Mini Game, A/B testing | [GitHub](https://github.com/Unity-Technologies/com.unity.services.samples.use-cases) | +| **UGS Samples** | Authentication flows, Remote Config, Cloud Code integration | [GitHub](https://github.com/Unity-Technologies/com.unity.services.samples) | +| **Gem Hunter Match** | Full 2D match-3 game with player hub, progression, social features, in-game store | [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/gem-hunter-match-2d-sample-project-278941) | +| **Boss Room** | 8-player co-op RPG using Netcode for GameObjects, Authentication, Multiplayer Services | [GitHub](https://github.com/Unity-Technologies/com.unity.multiplayer.samples.coop) | + +Treat the samples' Cloud Code as legacy (see [Cloud Code in Building Blocks and Samples](#cloud-code-in-building-blocks-and-samples)). diff --git a/skills/build-live-game/references/cloud-code-cloudbehaviours.md b/skills/build-live-game/references/cloud-code-cloudbehaviours.md new file mode 100644 index 0000000..19b91bc --- /dev/null +++ b/skills/build-live-game/references/cloud-code-cloudbehaviours.md @@ -0,0 +1,1111 @@ +# CloudBehaviours Reference + +Server-authoritative game logic authored as a C# class, with the client bindings — RPCs, synchronized +state, push events — generated for you. Part of `com.unity.services.cloudcode`, requiring Unity 6000.5+. + +Read this when the goal needs cloud-owned state that clients read but cannot write: shared match state, +per-player secret state, server-owned timers. For request/response server logic with nothing to +synchronize, plain Cloud Code modules are generally available and the better default. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Anti-Hallucination Reference](#anti-hallucination-reference) +- [The authoring model](#the-authoring-model) +- [What `public` means on the cloud class](#what-public-means-on-the-cloud-class) +- [Scopes](#scopes) +- [Deployed names](#deployed-names) +- [Generated code is not editable](#generated-code-is-not-editable) +- [Client bindings](#client-bindings) +- [Per-viewer state with `[SyncView]`](#per-viewer-state-with-syncview) +- [Calling another behaviour](#calling-another-behaviour) +- [Timers](#timers) +- [State, serialization, and model types](#state-serialization-and-model-types) +- [Endpoint access control](#endpoint-access-control) +- [Local development, debugging, and deployment](#local-development-debugging-and-deployment) +- [Generator diagnostics](#generator-diagnostics) +- [Silent failure modes](#silent-failure-modes) + +## Prerequisites + +| Requirement | Detail | +| :-- | :-- | +| `com.unity.services.cloudcode` **`3.1.0-exp.1`**+ | Confirm in the project with **Create > Services > Cloud Behaviour Script** in the create menu | +| Editor is 6000.5 or newer | every CloudBehaviours assembly is gated on `UNITY_6000_5_OR_NEWER` | +| Project is linked to a Unity Cloud project | required to deploy, and to authenticate at runtime | + +`Scope.MultiplayerSession` additionally needs `com.unity.services.multiplayer` installed. Player-scoped +behaviours do not. One module can hold CloudBehaviours and native Cloud Code classes side by side. + +If the create-menu entry is missing, say so and do not write CloudBehaviour code: the installed version +does not carry CloudBehaviours and no amount of correct code will compile against it. Fall back to a plain +stateful module unless the user asks for the experimental package. + +## Anti-Hallucination Reference + +| Correct | Incorrect (do NOT use) | +|---|---| +| `[StateScope(Scope.Player)]` / `[StateScope(Scope.MultiplayerSession)]` on the cloud class | any other class attribute to mark a behaviour -- `[StateScope]` is the only one | +| cloud class inherits `CloudBehaviour` | a plain class with only `[StateScope]` -- that is a native module, not a behaviour | +| `public int Round;`, `public int Round { get; set; }` or `public int Round { get; private set; }` -- all synchronize | a get-only or computed property -- ignored with warning `CCSG113`; expose a computed value with `[SyncView]` | +| `public` members reach clients; `private` ones never leave the cloud | assuming access control hides a `public` field, or that a `private` one is synchronized | +| `[CloudCodeService] ITimerService timers` as a method parameter | an unannotated `ITimerService` parameter -- build error `CCSG136` | +| `await self.EndTurn(arg).ScheduleAsync(delay)` on the injected `I{Behaviour}` | assembling an endpoint-name string by hand to schedule work | +| `[CloudCodeService] ITimerService` + `GetTimerAsync(id)` to cancel | expecting a cancel on `ICall` -- it has none | +| `await other.Endpoint(arg).ForScope(scopeId)` to invoke another scope instance now | `ScheduleAsync` for a cross-scope call -- it only ever targets the executing instance | +| `[CloudCodeAccess(Access.Service)]` on a timer target | leaving it callable by clients because "only the timer calls it" | +| `Access.Service` endpoints called only from behaviours, via `I{Behaviour}` | calling one from the client -- the generated client has no method for it | +| `Context.ServiceToken` inside a timer callback | `Context.PlayerId` -- it is `null`; a timer runs for no player | +| `[SyncView("Hand")] internal List ProjectHand(string playerId)` | a `private` projection -- build error `CCSG142` | +| client partial: `OnEnable` -> `EnableClient()`, `OnDisable` -> `DisableClient()` | hand-writing `EnableClient`, `Hydrate`, an RPC, or a synchronized property -- they are generated | +| client calls the generated method: `await behaviour.PlayTurn(move)` | `CallModuleEndpointAsync("Behaviour", "PlayTurn", args)` from a behaviour's client | +| `Synchronized`, `ScopeChanged`, `Fatal`, `Error`, `MessageReceived` on the generated bindings | any other event name for connection or state callbacks | +| read synchronized fields in a `ScopeChanged` handler -- the client hydrated before raising it | calling `Hydrate()` on connect or reconnect -- it already ran, and yours is a second RPC | +| `List`, `T[]`, `Dictionary<,>` across the boundary | a custom `IEnumerable` type -- compile-time error | +| model types carrying only fields and properties | methods or constructors on a model -- not regenerated client-side; a computed or get-only property is error `CCSG131` | +| behaviour class names unique within a module | two behaviours sharing a simple name across namespaces -- generator error | + +## Creating the module + +Always create a new module with **Create > Services > Cloud Behaviour Script**, because the creator writes +the module's `.ccmu` asset along with both assemblies, and the Deployment window lists only modules that +have a `.ccmu`. A module built from hand-written asmdefs alone compiles and generates bindings, but never +appears in the Deployment window, and nothing reports why. An agent without computer-use tools asks the +user to run this menu step; with them, it runs the step itself. + +The first behaviour in a folder opens the Cloud Behaviour Module Creator, which writes +`.ccmu` at the module root and scaffolds two assemblies: + +| Folder | Assembly references | Platform | +| :-- | :-- | :-- | +| `Cloud/` | `Unity.Services.CloudCode.Core`, `Unity.Services.CloudCode.Apis`, `Unity.Services.CloudBehaviours.Editor`, `Unity.Services.CloudCode.SourceGenerator`, `Newtonsoft.Json.dll` | Editor only, `noEngineReferences` | +| `Client/` | `Unity.Services.CloudBehaviours.Runtime`, `Unity.Services.Core`, `Unity.Services.CloudCode`, `Unity.Services.CloudCode.SourceGenerator`, **the cloud assembly**, and for `Scope.MultiplayerSession` also `Unity.Services.CloudBehaviours.Multiplayer.Runtime` + `Unity.Services.Multiplayer` | all | + +Two entries in that `Client/` row are easy to get wrong, and both fail as `CCSG127` plus a wall of +`CS0103` on the client partial: + +- **The client assembly must reference the cloud assembly**, even though the cloud assembly is + Editor-only. This is correct and does not break player builds: the generator emits stubs of the cloud + types into the client assembly, so nothing Editor-only is carried into a player. +- **A session-scoped behaviour needs the two multiplayer assemblies on the *client* assembly**, because + the generated client addresses its state through a `MultiplayerScopeProvider`. Installing + `com.unity.services.multiplayer` is not sufficient on its own — the references have to be there too. + +Use the table to check what the creator generated. The creator also wires the cloud reference, which a +hand-written client asmdef usually misses. The split matters: the cloud assembly has no engine +references, so `UnityEngine` types are unavailable server-side, and the generator needs both assemblies to +reference it. + +If the user already has hand-written assemblies and the module is missing from the Deployment window, the +module is missing its `.ccmu`. Add one instead of rebuilding the module, because a new `.ccmu` leaves every +assembly name, GUID and script reference as it is: + +1. Stop until the user confirms a rollback point, such as a commit in their version control or a backup + copy of the project, so a failed repair has a clean way back. +2. Check the cloud asmdef against the `Cloud/` row of the table above and add any missing references. The + create menu refuses to add behaviours to a module whose cloud assembly lacks them. +3. Write `.ccmu` in the folder that holds both assembly folders, in the format and with the + GUID rules in the `.ccmu` File Format section of `references/cloud-code-modules.md`. + Take each GUID from the `guid:` line of the matching `.asmdef.meta` in this project, never from + anywhere else, because a GUID resolves only in the project that minted it. +4. Verify: the `.ccmu` imports with zero console errors, and the module is listed in + **Services > Deployment** under the cloud asmdef's name. If either check fails, restore the rollback + point and read the console error before trying again. + +## The authoring model + +What you write, and what the generator writes for you: + +1. **The creation flow scaffolds two assemblies** — a `Cloud/` assembly (Editor-only, no engine + references) and a `Client/` assembly, each referencing `Unity.Services.CloudCode.SourceGenerator`. +2. **You implement the behaviour on the cloud side**: a class inheriting `CloudBehaviour` with a + `[StateScope]`, public methods for the operations, public fields for the state clients should see, and + private members for everything that stays server-side. +3. **The source generators run on every compile** and emit two halves from that one class: + - the **Cloud Code module** itself — a `[CloudCodeFunction("Behaviour__Method")]` per public method, + the `Behaviour_Hydrate` endpoint, and the synchronization that pushes state to clients in scope. + This is what gets deployed and what the service runs. + - the **client bindings** — the other half of your `partial` client class, carrying the RPC methods + (except `Access.Service` endpoints, which clients cannot call), + the read-only synchronized properties and the `UnityEvent`s your game code calls and subscribes to. +4. **Your game client uses the bindings** like any other component: enable it, call the generated + methods, react to `Synchronized` and to your own push events. + +Neither generated half appears under `Assets/`; both are compiler output, regenerated on each compile. + +```csharp +// Cloud/MatchState.cs — cloud assembly, runs in Cloud Code +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Unity.Services.CloudBehaviours; +using Unity.Services.CloudCode.Core; + +[StateScope(Scope.MultiplayerSession)] +public class MatchState : CloudBehaviour +{ + public int Round; // synchronized to every client in scope + private List _deck; // stays in the cloud + + public event Action RoundEnded; // push channel + + public string PlayTurn(string move) // RPC + { + Round++; + Logger.LogDebug("Round {Round}", Round); // Logger comes from the base class + RoundEnded?.Invoke(Context.PlayerId); // Context is IExecutionContext + return "ok"; + } +} +``` + +```csharp +// Client/MatchStateClient.cs — client assembly +using Unity.Services.CloudBehaviours; +using UnityEngine; + +[CloudBehaviour(typeof(MatchState))] +public partial class MatchStateClient : MonoBehaviour +{ + protected virtual void OnEnable() => EnableClient(); + protected virtual void OnDisable() => DisableClient(); + + async void OnMoveButton() + { + var result = await PlayTurn("rock"); // generated RPC + } +} +``` + +Write only `OnEnable`/`OnDisable` and your own logic. `EnableClient`, `DisableClient`, `Hydrate`, the +RPC methods, the synchronized properties, and the `UnityEvent`s are all generated — implementing any of +them by hand collides with the generated partial. + +## What `public` means on the cloud class + +Accessibility is the opt-in mechanism, which is why secrets simply stay `private`: + +| Cloud class member | Becomes | On the client | +| :-- | :-- | :-- | +| `public` method | RPC | `async Task` / `async Task` method | +| `public` field (non-static, non-const, non-readonly), or `public` property with a public getter and a setter of any accessibility | synchronized variable | `public T Name { get; private set; }` | +| `public event Action`, `Action<…>`, `EventHandler`, `EventHandler` | push channel | `UnityEvent` / `UnityEvent<…>` | +| method with `[SyncView("name")]` | per-viewer projection | read-only property named `name` | +| parameter with `[CloudCodeService]` | injected service | omitted from every caller-facing signature — the client RPC and `I{Behaviour}` alike | + +Non-public members are invisible to clients and to the generator. + +Two traps live in that table: + +- **A service parameter needs `[CloudCodeService]`.** `Task StartTurn(ITimerService timers)` fails the build with `CCSG136`, because a Cloud Code service or generated `I{Behaviour}` can't arrive in a request payload. Annotate every injected service. +- **Read-only members don't synchronize.** A get-only or computed property, or a `readonly` field, is ignored with warning `CCSG113`; a `public static` or `const` member is skipped with warning `CCSG158`. Declare synchronized state as a public field or a property with a setter (`{ get; private set; }` is fine), expose a computed value through a `[SyncView]` method, and mutate a collection's contents rather than reassigning a read-only one. The client still sees it as a read-only property. + +## Scopes + +`[StateScope(...)]` binds the instance's state and decides who may call it. + +| Attribute | State | Default access | Use for | +| :-- | :-- | :-- | :-- | +| `[StateScope(Scope.Player)]` | per player, persistent | `Access.Player` | inventory, progression, inboxes | +| `[StateScope(Scope.MultiplayerSession)]` | per session, shared by all members | `Access.SessionMember` | turn state, shared match state | + +`[StateScope]` is **mandatory**. A behaviour without one is `CCSG126`, an error that aborts generation +for the whole module — there is no stateless or non-scoped CloudBehaviour. (Plain Cloud Code functions +do default to `Access.Global` when unscoped; behaviours do not work that way.) For stateless +request/response work, use a plain Cloud Code module instead. + +New scripts are scaffolded as `Scope.Player`. Persistent private state → `Player`; shared volatile +state → `MultiplayerSession`. Override per endpoint with `[CloudCodeAccess(...)]` — see +[Endpoint access control](#endpoint-access-control). + +## Deployed names + +The generator emits one Cloud Code function per endpoint, and these strings appear in logs, in the +Deployment window, and anywhere a function name is passed as a string (timers, for instance): + +| Thing | Deployed name | +| :-- | :-- | +| RPC `Method` on behaviour `B` | `B__Method` | +| Hydration | `B_Hydrate` (single underscore) | +| State sync push | `B__Synchronize` | +| Push event `Ev` | `B__Ev` | + +Two behaviours in one module may declare the same *method* name — bindings resolve by full type identity. +Their **class** names must differ, though, even across namespaces: the generated `{module}Extensions` +aggregator derives `Add{Name}Client` and short-name type references from the simple name, so duplicates +are reported as an error. + +Names must also be unique *within* a behaviour, across kinds. An endpoint, a synchronized member, an +event, and a `[SyncView]` projection all share one wire key namespace, and the comparison is +case-insensitive, so `Score` and `score` collide. Overloads collide for the same reason — one wire key, +two methods. A same-kind duplicate keeps the first declaration with warning `CCSG117`, which shows up as a member +that mysteriously does not exist on the client. Members of different kinds sharing a name have no +survivor, so all of them are dropped and the build fails with error `CCSG118`. Event payloads are +limited to four parameters. + +## Generated code is not editable + +The generator runs on every compilation and emits sources the compiler holds in memory — they never +appear under `Assets/` and are not checked in. To regenerate, recompile. When generated code looks +wrong, fix the cloud class or the bindings class; there is nothing to edit in the output. + +### Reading the generated sources + +Not editable is not the same as not inspectable, and reading them is often the fastest way to close the +loop on "the client says that member does not exist". Roslyn can dump every generator's output to disk, +but the setup has three traps, so follow it exactly. + +**1. Put the `csc.rsp` next to the `.asmdef`, never at the `Assets/` root.** A root `csc.rsp` is applied +to every assembly in the project, packages included. Roslyn names output files by generator, not by the +assembly being compiled, so dozens of parallel compiles write the same paths and fail with `CS0016`. Add +one file per assembly you want to read, each naming its own output folder: + +``` +# Assets/MyGame/Client/csc.rsp (beside MyGame.Client.asmdef) +/generatedfilesout:Generated/MyGame.Client +``` + +**2. Create that folder before you compile.** The path is relative to the project root, not `Assets/`. +Neither Unity nor Roslyn creates it; Roslyn only creates the per-generator subfolders beneath it. Without +it, every assembly using the rsp fails with: + +``` +error CS0016: Could not write to output file '\Generated\...\AssemblyMonoScriptTypes.generated.cs' + -- 'Could not find a part of the path ...' +``` + +The message names a generated file, so it reads like a generator bug. It is a missing folder. If the rsp +was global, `Unity.Pipeline.CodeGen` is among the casualties and `unity status` reports no Pipeline +instance while the Editor is open in front of you; delete the rsp to recover. + +**3. Keep the folder outside `Assets/`.** Unity would import the dumped `.cs` files and compile them again. + +Recompile, and the output lands under the folder you named: + +``` +Generated/ + MyGame.Client/ + CloudCodeSourceGenerator/ + SourceGenerator.Generators.BehaviourBindingsGenerator/ <-- client partial + its inspector + MyGame.Client.MatchStateClient.g.cs + MyGame.Client.MatchStateClient_Inspector.g.cs + MyGame.Client.MatchState_Sync.g.cs + MyGame.Cloud/ + CloudCodeSourceGenerator/ + SourceGenerator.Generators.BehaviourManifestGenerator/ <-- the deployed cloud module + MyGame.Cloud.IMatchState.g.cs + MyGame.Cloud.MatchState_System.g.cs + MyGame.Cloud.MatchState_Sync.g.cs + MyGame.Cloud.__MatchStateCcClient.g.cs + MyGameExtensions.g.cs +``` + +The file name is the hint name — `{Namespace}.{Type}.g.cs` — so a member you expected on the client is +either in `…MatchStateClient.g.cs` or it was never generated, which is the whole diagnosis. Read the +bindings file to see what the client actually got, and the manifest file to see the endpoint names and +the `_Sync` payload the cloud will push. + +This is build output: it is rewritten on every compile, editing it changes nothing, and it does not +belong in source control. It is also a trap for everyone else: if the `csc.rsp` is committed and +`Generated/` is ignored, the folder does not exist on a fresh clone and the project fails to compile for +anyone but its author. Either keep the `csc.rsp` files uncommitted, or commit a `.gitkeep` in each output +folder and ignore only the contents: + +``` +Generated/**/*.cs +``` + +## Client bindings + +### What the generator emits + +For `[CloudBehaviour(typeof(TCloud))] public partial class MyClient : MonoBehaviour`, the generated half +of the partial contains: + +```csharp +[field: SerializeField] internal string TargetSessionType { get; private set; } // MultiplayerSession scope — or: +[field: SerializeField] internal string TargetPlayerId { get; private set; } // Player scope + +public CloudCodeScope Scope { get; } // the scope provider's current scope +public bool IsClientEnabled { get; } + +public UnityEvent ScopeChanged; +public UnityEvent Fatal; +public UnityEvent Error; +public UnityEvent MessageReceived; +public UnityEvent Synchronized; + +public void EnableClient(); +// MultiplayerSession scope also: EnableClient(string sessionType), EnableClient(ISession), +// EnableClient(SessionObserver), EnableClient(MultiplayerSession) +// Player scope also: EnableClient(string playerId) +public void DisableClient(); +public async Task Hydrate(); + +// one read-only property per synchronized member: public int Round { get; private set; } +// one UnityEvent per push channel: public UnityEvent RoundEnded; +// one async method per client-callable RPC: public async Task PlayTurn(string move); +``` + +The binding creates a `PlayerScopeProvider` or `MultiplayerScopeProvider` according to the cloud +class's `[StateScope]`, so the common case needs no wiring. The provider isn't exposed; choose what to +bind to with the `EnableClient` overload instead. + +### Lifecycle + +```csharp +protected virtual void OnEnable() => EnableClient(); +protected virtual void OnDisable() => DisableClient(); +``` + +`EnableClient()` subscribes to the provider and starts resolving the scope; `DisableClient()` tears that +down. Calling any `EnableClient` overload twice without an intervening `DisableClient()` logs a warning and +does nothing. To rebind to a different target, call `DisableClient()` first. + +RPCs need a resolved scope. Rather than firing calls immediately after enabling, drive them from user +input or from `ScopeChanged`, which raises once the provider has a scope and the client has tried to +hydrate against it. Gate state-dependent work on `Synchronized`, or on the absence of +`HydrationFailedOnConnect` (see [Hydration](#hydration)), because `ScopeChanged` also follows a failed hydrate. `Scope` itself goes non-null earlier — the moment the provider resolves — so polling +`Scope != null` hands you a scope whose synchronized fields are not populated yet. + +### Choosing what to bind to + +`TargetSessionType` and `TargetPlayerId` are `internal` properties with a serialized backing field, so +they are editable in the Inspector (**Target Session Type**, **Target Player ID**) but not settable from +other code. Leaving them empty is the useful default: + +| Scope | Target | Empty means | Set means | +| :-- | :-- | :-- | :-- | +| `MultiplayerSession` | `TargetSessionType` | adopt the single active session; if more than one is active the binding reports `CloudBehaviourExceptionReason.AmbiguousSessions` | watch only for sessions of that type — must match `SessionOptions.Type` used at create/join/matchmaking | +| `Player` / none | `TargetPlayerId` | follow the signed-in player | bind to that specific player id | + +To choose the target from code, call the matching overload instead: `EnableClient(sessionType)`, +`EnableClient(ISession)`, `EnableClient(SessionObserver)` or `EnableClient(MultiplayerSession)` for +session scope, and `EnableClient(playerId)` for player scope. An overload replaces any target set in the +Inspector. + +### Reacting to state and events + +Synchronized members arrive as read-only properties; `Synchronized` raises after each applied update +(an RPC return, an incoming push, or the hydrate that runs when the scope resolves). Refreshing UI +from `Synchronized` decouples the view from individual RPCs: + +```csharp +protected virtual void OnEnable() +{ + EnableClient(); + Synchronized.AddListener(RefreshUi); + RoundEnded.AddListener(OnRoundEnded); +} + +protected virtual void OnDisable() +{ + Synchronized.RemoveListener(RefreshUi); + RoundEnded.RemoveListener(OnRoundEnded); + DisableClient(); +} + +void OnRoundEnded(string playerId) => Debug.Log($"round ended by {playerId}"); +``` + +Remove runtime listeners in `OnDisable`: `UnityEvent` keeps them across disable and enable, so re-enabling would add duplicates. + +Because these are `UnityEvent`s, listeners can also be wired in the Inspector. + +### Hydration + +A client hydrates itself when its scope resolves: it pulls current state through the `B_Hydrate` +endpoint, applies it, and only then raises `ScopeChanged`, with `Synchronized` immediately after. A +`ScopeChanged` handler can therefore read synchronized fields directly — they are already current, +provided the hydrate succeeded (see below) — and a client that connects and does nothing else still holds server state. + +If that first hydrate fails, `Error` is raised with `HydrationFailedOnConnect` *before* +`ScopeChanged`; `ScopeChanged` still follows, no `Synchronized` does, and the fields keep their +previous or default values. An `Error` handler therefore runs on a client that has not yet announced +its scope — `Scope` is already set, so calling `Hydrate()` from that handler is legal. + +Every RPC return also carries the caller's fresh view of synchronized state, so a client that calls +an RPC does not need to hydrate either. + +That leaves `Hydrate()` for two cases: catching up inside a live scope after a period of not +listening, and recovering from `HydrationFailedOnConnect`. A reconnect is not one of them — it +re-resolves the scope, which hydrates. + +Connecting costs one hydrate RPC per scope resolution, per client. A pooled client toggled +repeatedly, or many behaviours on one player, pays it each time. + +### Failures + +Two events, split by whether the client survives. + +`Fatal` carries a `CloudBehaviourException` whose `Reason` is one of `Unknown`, `SubscriptionError`, +`Kicked`, or `AmbiguousSessions`. The client is **already disabled** by the time handlers run — +`IsClientEnabled` is false and nothing further arrives until `EnableClient()` is called again. Do not +call it from the handler: a failure that reproduces immediately would recurse. `AmbiguousSessions` in +particular means the project has multiple active sessions and the binding cannot guess — set +**Target Session Type** in the Inspector, or call `EnableClient(sessionType)`. + +`Error` carries a `CloudBehaviourError` and reports a failure the client **carries on from**: it keeps +its `Scope` and stays enabled. Read `Reason` to decide whether to retry. Today the only value besides +`Unknown` is `HydrationFailedOnConnect` — the client could not fetch server state when its scope +resolved, so synchronized fields still hold their previous or default values. Call `Hydrate()` to try +again. + +```csharp +Fatal.AddListener(e => Debug.LogError($"client stopped: {e.Reason}")); +Error.AddListener(e => +{ + if (e.Reason == CloudBehaviourErrorReason.HydrationFailedOnConnect) + _ = Hydrate(); +}); +``` + +Both also log to the console, so an unhandled failure is not silent. + +## Per-viewer state with `[SyncView]` + +A plain synchronized field sends the same value to everyone in scope. When each player must see a +*different* value — their own hand, their own fog of war, their own private balance — express it as a +projection instead. + +`[SyncView("wireName")]` marks a method as the synced member. The method **is** the state on the wire: +the generator emits one member named `wireName`, typed as the method's return type, and fills it per +recipient by calling the method with that player's id. + +```csharp +[StateScope(Scope.MultiplayerSession)] +public class CardGame : CloudBehaviour +{ + [CloudCodeSerializeProperty] + private Dictionary> _hands = new(); // every hand: persisted, cloud-only + + [SyncView("Hand")] + public List ProjectHand(string playerId) => + _hands.TryGetValue(playerId, out var hand) ? hand : new List(); +} +``` + +The client sees `public List Hand { get; private set; }` holding only that player's cards. The +unfiltered dictionary never leaves the cloud, which is the point: filtering client-side would ship every +hand to every player. + +### Requirements + +- Signature is `TWire Method(string playerId)` — one `string` parameter, non-`void` return. +- The method must be `public` or `internal`. A `private` projection is build error `CCSG142`, so + `internal` is the tidy choice: visible to the generator, invisible outside the assembly. A `[SyncView]` method never becomes an RPC even when public, because + projections are partitioned out before endpoints are collected. +- It must be synchronous and non-generic: `async`, `Task`-returning, generic or `static` methods are not + captured. +- The `name` argument is required and non-empty; it is the member name clients see, so it should read + like a field (`"Hand"`, not `"ProjectHand"`). +- The `playerId` passed in is always the id of the player the payload is being built for — the viewer + about to receive this value, not whoever triggered the call. On an RPC return that is the caller, on a + push it is each recipient in turn, and on `Hydrate()` it is the player asking. Build the value for + *that* player and nothing else. +- When the invocation has no calling player — a timer callback, or a cross-behaviour call — the + **return value** carries an empty envelope rather than calling projections with an empty id. This is + the RPC return only. Pushes are unaffected: they are built per recipient, so each one still has a real + player id and projections resolve normally. See + [What a timer callback still pushes](#what-a-timer-callback-still-pushes). +- Keep the method side-effect free. It runs once per recipient on every synchronization, so mutating + state there produces surprising repeated writes. +- The projection runs for the calling player's own view as well, so a caller sees their projected value + in the RPC return, not the raw state. + +### Sending to specific players + +Per-viewer control applies to synchronized state. A declared event is pushed to everyone in scope, and a +check inside the method decides only whether the event fires, not who receives it. When only some players +should hear about something, push to each of them with +`Push.SendPlayerMessageAsync(Context, message, messageType, playerId)` instead of invoking an event. + +## Calling another behaviour + +The generator emits an `I{Behaviour}` interface for every behaviour in the module, with one member per +endpoint. A member does not invoke anything — it returns an unexecuted **`ICall`** (or `ICall` when +the endpoint returns a value) describing the invocation. Inject the interface with `[CloudCodeService]`, +name the endpoint with its real arguments, then choose how to run it: + +| Execution mode | Runs | Against | Returns | +|---|---|---|---| +| `ScheduleAsync(delay)` | later, after `delay` | the scope instance that is **executing** | `Task` — the timer's id | +| `ForScope(scopeId)` | immediately | another instance of the **target's** scope | `Task`, or `Task` carrying the endpoint's return value | + +The two are alternatives. A described call handed to neither fails the build with `CCSG133`. + +Use `ScheduleAsync` for timers ([Timers](#timers)). Use `ForScope` for everything else: one behaviour +invoking another right now, including across different scopes. + +> **Do not call `ScheduleAsync` on another behaviour's `I{Behaviour}`**: it fails the build with `CCSG132`. +> `ScheduleAsync` is only for deferring **your own** behaviour's endpoints. +> +> To defer work against another behaviour, schedule one of your own endpoints and do the cross-scope +> call from inside it: +> +> ```csharp +> // in MatchRoom +> public async Task EndMatchSoon([CloudCodeService] IMatchRoom self) => +> await self.FlushScores().ScheduleAsync(TimeSpan.FromSeconds(30)); // own endpoint: fine +> +> [CloudCodeAccess(Access.Service)] +> public async Task FlushScores([CloudCodeService] ILeaderboard leaderboard) +> { +> foreach (var entry in _scores) +> await leaderboard.SubmitScore(entry.Value).ForScope(entry.Key); // cross-scope: ForScope +> } +> ``` + +### Cross-scope invocation with `ForScope` + +`ForScope(scopeId)` targets a specific instance of the callee's scope. The `scopeId` is a player id or a +multiplayer session id, matching the target behaviour's own `[StateScope]` — not the caller's. + +```csharp +using System.Collections.Generic; +using System.Threading.Tasks; +using Unity.Services.CloudBehaviours; +using Unity.Services.CloudCode.Core; + +[StateScope(Scope.Player)] +public class Leaderboard : CloudBehaviour +{ + public int BestScore; + + [CloudCodeAccess(Access.Service)] // no client method is generated; a behaviour can call it + public int SubmitScore(int score) + { + if (score > BestScore) BestScore = score; + return BestScore; + } +} + +[StateScope(Scope.MultiplayerSession)] +public class MatchRoom : CloudBehaviour +{ + [CloudCodeSerializeProperty] private Dictionary _scores = new(); + + [CloudCodeAccess(Access.SessionHost)] + public async Task EndMatch([CloudCodeService] ILeaderboard leaderboard) + { + foreach (var entry in _scores) + { + // entry.Key is a player id: the scope id of that player's Leaderboard instance + var best = await leaderboard.SubmitScore(entry.Value).ForScope(entry.Key); + Logger.LogInformation($"{entry.Key} best is now {best}"); + } + } +} +``` + +`MatchRoom` is session-scoped and `Leaderboard` is player-scoped, so each call names the player whose +instance it should land on. Awaiting an `ICall.ForScope(...)` gives that endpoint's return value +directly — the callee's synchronized members are not part of what the caller receives. + +### Requirements + +- **Both behaviours must live in the same module** (the same cloud assembly). `I{Behaviour}` and the + `{module}Extensions` registrations are generated per module. If the target is in another module, this + route does not exist — call it as a plain Cloud Code module instead. +- **`[CloudCodeService]` on the injected parameter is mandatory.** Without it the build fails with + `CCSG136`. +- **Injected parameters are not part of the interface.** The generator strips every + `[CloudCodeService]` parameter from `I{Behaviour}`, exactly as it does from the client bindings, so + name only the endpoint's real arguments. Do not pass `null` as a placeholder for an injected slot — + there is no slot. An endpoint declared + `Task FlushScores(int round, [CloudCodeService] ILeaderboard leaderboard)` is called as + `self.FlushScores(round)`. +- **Lock the target down.** A callable endpoint is callable by clients too. Anything only a behaviour + should invoke wants `[CloudCodeAccess(Access.Service)]` — a cross-behaviour call arrives as a service + call, so the restriction blocks clients while letting the caller through, and the generated client has + no method for it. See + [Endpoint access control](#endpoint-access-control). +- **Expect no calling player.** A service invocation has no player behind it: `Context.PlayerId` is + `null` and there is no player access token. Pass what the endpoint needs as arguments, and use + `Context.ServiceToken` for outbound service calls. +- **Class names must be unique within the module**, even across namespaces. + +## Timers + +Timers are server-authoritative: the cloud schedules a call back into one of the behaviour's own +endpoints, so a player who closes the game cannot dodge a turn clock. + +Scheduling uses the same generated `I{Behaviour}` interface described in +[Calling another behaviour](#calling-another-behaviour): inject it, name the endpoint with its real +arguments, and run the resulting `ICall` with `ScheduleAsync(delay)`. That defers the invocation on the +scope instance that is executing. + +```csharp +using System; +using System.Threading.Tasks; +using Unity.Services.CloudBehaviours; +using Unity.Services.CloudCode.Core; + +[StateScope(Scope.MultiplayerSession)] +public class TurnClock : CloudBehaviour +{ + public string CurrentTimerId; // persisted, so it survives to the next call + public int TurnNumber; + + [CloudCodeAccess(Access.SessionHost)] + public async Task StartTurn([CloudCodeService] ITurnClock self) + { + CurrentTimerId = await self.EndTurn(TurnNumber).ScheduleAsync(TimeSpan.FromSeconds(30)); + } + + [CloudCodeAccess(Access.Service)] + public void EndTurn(int turnNumber) // the scheduled endpoint + { + if (turnNumber != TurnNumber) return; // a stale timer fires against an old turn + TurnNumber++; + } + + [CloudCodeAccess(Access.SessionHost)] + public async Task CancelTurn([CloudCodeService] ITimerService timers) + { + var timer = await timers.GetTimerAsync(CurrentTimerId); + timer.Cancel(); + } +} +``` + +Arguments are ordinary typed parameters on the endpoint — no dictionary of loosely-typed values, and no +function-name string to assemble. Under the hood `ScheduleAsync` registers a timer against the deployed +endpoint for you. + +### Cancelling + +`ScheduleAsync` returns the timer's id. `ICall` has no cancel of its own, so keep injecting +`[CloudCodeService] ITimerService` for that: `GetTimerAsync(id)` returns the timer, which exposes +`RemainingTime()` and `Cancel()`. Persist the id if you will ever need it — a later invocation gets a +fresh instance. + +### Lock the target down, and don't expect a player + +A scheduled endpoint is still an endpoint, so a client can call it directly. Restrict it with +`[CloudCodeAccess(Access.Service)]`: a timed invocation arrives as a service call, so the timer still +fires while clients are blocked. The generated client has no method for it, so a stray client call is a +compile error. + +That same identity difference shapes what the endpoint can assume when a timer runs it: + +- `Context.PlayerId` is `null` — the invocation is not on behalf of anyone, so branching on the "calling + player" does not work. Pass what you need as arguments, or read it from state. +- there is no player access token; use `Context.ServiceToken` for outbound service calls. +- session data is refreshed before it runs, so `Context.Session.PlayerIds` reflects who is in the session + at that moment — handy for skipping players who left. + +### What a timer callback still pushes + +A timer runs for no player, but clients still see what it changes. A timer-triggered invocation pushes +to the same recipients as any other call: every session member for `Scope.MultiplayerSession`, and the +owner for `Scope.Player`. + +- **Synchronized state is pushed** to each recipient, with each payload built against that recipient's + id, so `[SyncView]` projections resolve per viewer as they do on a client call. The service fills + `Session.PlayerIds` for a session-scoped behaviour whatever the endpoint's access level, so a + service-token invocation still reaches every member. +- **Declared events are pushed** to the same recipients, carrying a full synchronization envelope. +- **Only the RPC return value is empty**, and only when the behaviour has projections. Nothing reads the + return of a timer invocation anyway. + +A value that fails to arrive after a timer fired is not explained by the timer. + +### Limits and patterns + +- The delay must be **greater than zero**. +- Each scope instance (one player, or one session) may hold at most **10 active timers**; registering + an eleventh throws. Cancelling frees the slot, so cancel the ones you no longer need rather than + letting them pile up. +- The delays of all active timers on a scope instance may add up to at most **24 hours** in + production, so no single timer can be longer than that. +- There is no recurring timer. For a repeating clock, have the scheduled endpoint schedule the next one, + keeping exactly one alive at a time. +- **Only schedule your own behaviour's endpoints.** Calling `ScheduleAsync` on another behaviour's + `I{Behaviour}` fails the build with `CCSG132`. See [Calling another behaviour](#calling-another-behaviour) for the + pattern that works. + +## State, serialization, and model types + +### Two separate rules: sync and persistence + +Whether a member reaches clients and whether it survives to the next invocation are decided +independently: + +- **Reaches clients** — determined purely by `public` accessibility. +- **Persists between invocations** — public members persist by default; two attributes override it, and + both apply to fields and properties: + +| Attribute | Effect | +| :-- | :-- | +| `[CloudCodeSerializeProperty]` | persists a non-public member — durable state the client never sees | +| `[CloudCodeIgnorePropertyAttribute]` (`[CloudCodeIgnoreProperty]`) | keeps a public member out of persistence while it still synchronizes | + +```csharp +[CloudCodeSerializeProperty] +private string _correctAnswer; // survives invocations, never sent to clients + +[CloudCodeIgnoreProperty] +public int FramesSinceStart; // synced for display, not worth persisting +``` + +Only serialized members survive: the runtime may rehydrate the instance between calls, so anything held +in a plain private field is gone by the next invocation. + +Custom serialization with `IStateSerializer` isn't supported on a CloudBehaviour yet, and implementing it +is error `CCSG157`. Don't suggest it for state migration; use the attributes above and keep saved members +backward compatible. + +A persisted data type can't hide an inherited member unless both are properties of the same type. The +server can't save two members with one name, so that pair is error `CCSG156`. Rename one, or add +`[CloudCodeIgnoreProperty]` to the one that doesn't need saving. + +`CCSG156` can't see non-public members of a type compiled in another assembly, such as a shared asmdef. +There, an `internal` or `private` member that hides a public one isn't reported, and every save of the +behaviour fails at runtime. Don't hide inherited members in shared data types. + +Synchronized state is a public field or a public property with a public getter and a setter of any +accessibility, the same accessor shapes that persist. A `readonly` field or a get-only or computed property is +ignored with warning `CCSG113`; use a `[SyncView]` method for a computed value. A `public static` or +`const` member is skipped with warning `CCSG158`. Data **types** crossing the +boundary are stricter: a member that can be read but not assigned there is error `CCSG131`. +`[CloudCodeSerializeProperty]` and `[CloudCodeIgnoreProperty]` still apply to both fields and properties +for persistence purposes. + +### Manual synchronization + +`AutoSynchronize` is `true` by default. After each endpoint returns, Cloud Code pushes the updated +synchronized state to the other clients in the scope, once per call however many fields changed, so a +single endpoint never needs to turn it off. + +Turn it off to batch changes from several calls into one push. `AutoSynchronize` is saved with the +behaviour's state, so setting it to `false` keeps it off for every later call in that scope, from any +player, until an endpoint sets it back to `true`. End the batch by setting it to `true` in the last call; +that call's normal push then carries everything: + +```csharp +public void BeginPlacement() +{ + AutoSynchronize = false; // later calls change state without pushing +} + +public void PlaceShip(int x, int y) +{ + Ships.Add(new Ship { X = x, Y = y }); +} + +public void LockPlacement() +{ + Locked = true; + AutoSynchronize = true; // this call's push carries the whole batch +} +``` + +Rules: + +- Write the endpoint that sets `AutoSynchronize` back to `true` in the same change that sets it to + `false`. Left off, state still saves and each caller still gets it in the RPC return, but no other + client receives a synchronization push, so the game looks frozen to everyone else. +- Events still push during a batch, and each one carries the full synchronized state, so raising an + event mid-batch sends the batch's changes early. +- Re-enable it on paths that abandon the batch too, such as a player leaving mid-placement or a round + timing out. The endpoint that starts the next round, or a timer callback, can do it. +- To push partway through a call, for example before a slow step, call `SynchronizeAsync()` and leave + `AutoSynchronize` on. The end-of-call push still follows. +- Don't initialise the field to `false`. That makes the behaviour fully manual, and every endpoint that + should reach other clients has to call `SynchronizeAsync()` itself. + +The calling client always receives current state in the RPC return, whatever `AutoSynchronize` is set to. + +Risks: + +- The flag belongs to the scope instance, not to the call or the player. One player's endpoint turning it + off stops the end-of-call push of every endpoint of that behaviour, for every player in the scope, and it + stays off across restarts of the scope because it is saved with the rest of the state. Only events and + explicit `SynchronizeAsync()` calls still push. +- Nothing warns when it is left off. The symptom is other clients no longer receiving updates from + endpoints unrelated to the batch, while each caller still sees current state in its own RPC return. When + pushes stop, look for an endpoint that set `AutoSynchronize = false` and a path that never set it back. +- Read-only endpoints cannot opt out of synchronization. An endpoint that changes nothing still pushes the + full synchronized state to every push recipient other than the caller: every other member of a + `Scope.MultiplayerSession`, or the owner of a `Scope.Player` scope when someone else calls it. An owner + calling its own player scope sends nothing. Don't turn `AutoSynchronize` off to avoid the push: it is the + same permanent switch. Accept it, and keep read-only session endpoints off hot paths, for example by + rate-limiting how often the client calls them. + +Who receives the push depends on scope. For `Scope.MultiplayerSession` the synchronization message goes +to `Context.Session.PlayerIds`, so session members see each other's changes without calling anything. +The caller is left out because the RPC return already gave it the state. + +A player-scoped behaviour's only client is its owner, so every push goes to the owner. When the owner +calls, the RPC return carries the state, so no synchronization push is sent, and events still reach the +owner. When a timer, a service call or another player's `Global` call changes the state, the owner gets +the synchronization push and the events. A caller who isn't the owner gets only the RPC return. Don't +design a player-scoped behaviour as though other clients will be pushed to. + +For a raw push outside synchronization, `SendMessageAsync(messageType, payload)` sends to every session +member for `Scope.MultiplayerSession`, and to the owner for `Scope.Player`. Declared events are usually +clearer than raw messages. + +### Model types + +Custom classes, structs, and enums referenced from public fields, properties, endpoint parameters/returns, +or event payloads are regenerated in the client bindings assembly. The regenerated version carries the +public fields and properties (as `{ get; set; }`) and enum members — nothing else. + +```csharp +// Good — data only +public class AttackResult +{ + public string TargetPlayerId; + public int X; + public bool WasHit; +} + +// Compiles, but the method is missing on the client +public class AttackResult +{ + public bool WasHit; + public bool IsSunk() => /* ... */; // methods aren't regenerated +} + +// Fails the build with CCSG131 +public class AttackResult +{ + public bool WasHit; + public string Summary => $"{WasHit}"; // readable but not assignable +} +``` + +A method is only dropped from the client stub. A member that can be read but not assigned, such as a +computed or get-only property, can't round-trip, so the build fails with `CCSG131`. + +Nested types referenced by a model are generated recursively and stay nested inside their containing +type. Enum bindings preserve the underlying type (`byte`, `long`, …), so values outside `int` range are +safe. Types that share a simple name across different namespaces are generated separately. + +A model type cannot inherit from a `[CloudCodeService]` type — service objects hold server-only state +that cannot be serialized. + +### Unsupported types + +A custom collection type — anything implementing `IEnumerable` that is not `List`, an array, or +`Dictionary<,>` — is a **compile-time error** when used as an endpoint parameter, an endpoint return, or a +data-type field, because it cannot round-trip through JSON. Use `List` or `T[]`. + +Everything crossing the boundary must be JSON-serializable: primitives, `string`, enums, plain +classes/structs, and `List`/arrays/dictionaries of those. + +## Endpoint access control + +Every CloudBehaviour endpoint has an access level. Left alone it is derived from the class scope, which +is the right answer most of the time: + +| Class scope | Resolved access | +| :-- | :-- | +| `[StateScope(Scope.Player)]` | `Access.Player` | +| `[StateScope(Scope.MultiplayerSession)]` | `Access.SessionMember` | + +Resolution happens when the module loads, so the attribute is only needed where an endpoint should differ +from its class default. + +```csharp +using Unity.Services.CloudBehaviours; +using Unity.Services.CloudCode.Core; + +[StateScope(Scope.MultiplayerSession)] +public class Match : CloudBehaviour +{ + public void SubmitMove(string move) { } // Access.SessionMember (default) + + [CloudCodeAccess(Access.SessionHost)] + public void StartMatch() { } // only the session host + + [CloudCodeAccess(Access.Service)] + public void GrantReward(string playerId, int amount) { } // only service-token callers +} +``` + +An `Access.Service` endpoint gets no method on the generated client binding, so calling it from game +code is a compile error rather than a runtime `403`. Behaviours still reach it through `I{Behaviour}` +(`ScheduleAsync`, `ForScope`). + +### The `Access` enum + +Which values are legal depends on the class's `[StateScope]`, so the enum groups by scope: + +| Valid on | Value | Who may call | +| :-- | :-- | :-- | +| **any scope** | `Unspecified` | resolve from the class scope at load time (the default) | +| | `Global` | any authenticated caller | +| | `Service` | service-token callers only — other services, trusted backends, timer invocations; never a game client | +| **`Scope.Player` only** | `Player` | the player who owns the scope | +| **`Scope.MultiplayerSession` only** | `SessionMember` | any member of the session | +| | `SessionHost` | the host of the session | + +An `Access` value the module cannot honour throws when the module loads rather than failing quietly, so +pass enum members and avoid casting integers into `Access`. + +### Choosing a level + +Reach for `Access.Service` on anything that mints value — granting currency, awarding items, adjusting +another player's state. `SessionHost` suits match flow control (start, force-advance, kick) where one +client is authoritative. Beyond that, the scope defaults are usually correct, and adding attributes that +merely restate them is noise. + +Access control is not a substitute for keeping state private: an endpoint restricted to `Service` still +exposes any `public` field to every client in scope. + +## Local development, debugging, and deployment + +### The local Cloud Code server + +Deploying to the local server is the fast loop: it runs the cloud assembly in a local process, supports +breakpoints, and lets Play mode talk to real cloud logic without a round trip to UGS. + +- Configuration lives on the `CloudCodeLocalServerSettings` asset — the listening **port** (default + **14750**) and an optional **secrets** `TextAsset`. The settings UI can create and assign a placeholder + secrets JSON file and open it in an external editor; invalid JSON surfaces an error dialog rather than + failing at runtime. +- A module whose source changed is **redeployed automatically** onto the running server — no stop/start + cycle. Redeploy happens after a recompile, outside Play mode only. **Nothing deploys to the local server + during Play mode**, not even an explicit `services_deployment_item_deploy`, which is refused. A + `404 function-not-found` for an endpoint added while playing means the module isn't deployed yet: exit + Play mode, wait for `Local Cloud Code server reloaded` in the Console, then re-enter. Same known limitation + as modules: see + the Deploying via the Deployment Window section of `references/cloud-code-modules.md`. +- The server won't start without an active services environment: see + the Running the Local Cloud Code Server section of `references/cloud-code-modules.md`. +- On startup the server checks for port conflicts and a usable .NET runtime, and a server that dies during + startup reports why. + +### The binding Inspector + +The generator also emits a custom Inspector for each bindings component, which is the fastest way to see +what a binding is actually doing at runtime. In Play mode it shows: + +- a live connection-status indicator and the resolved player or session identifier — so a binding that + never resolved a scope is visible without adding a single log line; +- the current value of every synchronized member and `[SyncView]` projection, read-only; +- `UnityEvent` slots for `ScopeChanged`, `Fatal`, `Error`, `MessageReceived`, `Synchronized`, and each + push event, so listeners can be wired without code. + +Check it before instrumenting anything. A missing member points at generation (see +[Generator diagnostics](#generator-diagnostics)); a resolved scope with stale values points at the push +path; no scope at all points at the lifecycle. + +### Logging + +Two independent switches: + +| Where | How | +| :-- | :-- | +| Server-side, inside a behaviour | `Logger` on the base class (`Microsoft.Extensions.Logging.ILogger`) | +| Cloud Code's own Editor/authoring logging, including local server operation | **Verbose Logging** in **Project Settings > Services > Cloud Code**, backed by the `ENABLE_UNITY_CLOUD_CODE_AUTHORING_VERBOSE_LOGGING` define | +| Client-side binding traffic — scope resolution, pushes, RPC calls | add the `ENABLE_CLOUDBEHAVIOUR_LOGS` scripting define; the generated bindings' log calls are `[Conditional]` on it, so they compile out without it | + +Local server infrastructure logging defaults to non-verbose, so turn Verbose Logging on before chasing a +deployment or connection problem. + +### Deployment + +CloudBehaviour modules appear in the **Deployment** window as Cloud Code modules and deploy from there to +the linked UGS project. A module asset is a `.ccmu` file; its inspector offers deployment actions and a +summary of the last successful deployment. The window finds modules only through their `.ccmu`, so a module +that is missing from it has no `.ccmu`. Repair it by adding one (see +[Creating the module](#creating-the-module)). + +Names come from the **cloud assembly (asmdef) name**, not the `.ccmu` asset name. While the local server +runs, an explicit deploy goes to it instead of the environment, so stop it before deploying remotely. + +A deployed behaviour runs in the same hosting model as any other Cloud Code module, so **cold start +applies**: after roughly 15 minutes without traffic the worker shuts down and the next call pays a +latency spike while it starts again. Budget for it in the UX — do not put a tight timeout on the first +call after an idle period, and do not treat that first slow response as a failure. + +### When the generated bindings do not compile + +Work through it in this order, since the later steps are rarer: + +1. Recompile. Generated sources are produced per compilation; a stale error can survive an interrupted + one. +2. Check both assemblies reference `Unity.Services.CloudCode.SourceGenerator`, and that the cloud + assembly references `Unity.Services.CloudBehaviours.Editor`. A missing reference is the most common + cause, and the generator's diagnostic names it. +3. Check the console for generator diagnostics — collisions between behaviour members, read-only members + skipped for synchronization, unsupported collection types, and duplicate names are all reported there + rather than as mystery type errors. +4. Confirm no generated member has been implemented by hand in the partial (`EnableClient`, + `DisableClient`, `Hydrate`, an RPC, a synchronized property). +5. Confirm the editor is 6000.5+ — both scaffolded assemblies are gated behind `UNITY_6000_5_OR_NEWER`, + so on an older editor the types simply do not exist and every reference to them fails. + +## Generator diagnostics + +The generator reports problems as compiler diagnostics with ids of the form `CCSG1xx` (CloudBehaviours; +`CCSG2xx` is native modules, `CCSG3xx` bindings). They surface in the Console and in the IDE like any +other compiler message, and severities can be adjusted through `.editorconfig`. + +**Read them before assuming the generator is broken.** Most CloudBehaviour problems are reported, name +the offending member, and say what was kept and what was dropped. + +**An error that aborts generation takes every generated member with it.** The client partial then has no +generated half, so a single `CCSG1xx` error surfaces as a pile of `CS0103 - does not exist in the current +context` on `EnableClient`, `DisableClient`, `Synchronized`, the RPCs and the synchronized properties. +Those are symptoms. Fix the `CCSG1xx` and they all go away together — editing the client partial to chase +them is wasted work. + +Common ones, by what they tell you: + +| Id | Situation | Severity | Fix | +| :-- | :-- | :-- | :-- | +| `CCSG126` | Behaviour class has no `[StateScope]` | error | Add `[StateScope]` to the class | +| `CCSG101` | Two behaviours in a module share a simple class name | error | Rename one behaviour so simple names are unique in the module | +| `CCSG120` / `CCSG122` | Name reserved by a generated type or framework type | error | Rename the member or class away from the reserved name | +| `CCSG115` | `Access` value incompatible with the class scope | error | Use an `Access` value valid for the class scope | +| `CCSG116` | `[CloudCodeAccess]` on something that is not an endpoint | error | Remove `[CloudCodeAccess]` or put it on an endpoint | +| `CCSG109` | `ref` / `out` parameter | error | Remove the `ref` / `out` modifier | +| `CCSG112` / `CCSG125` | Generic behaviour class, or a nested one | error | Make the behaviour class non-generic and top-level | +| `CCSG110` / `CCSG111` | Unsupported awaitable return, or `async void` | error | Return `Task`, `Task` or a plain value, and never `async void` | +| `CCSG128` / `CCSG129` | Custom collection type in a signature or data-type field | error | Use `List`, arrays or dictionaries instead of a custom collection | +| `CCSG131` | Data-type member that is readable but not settable, including a `readonly` field | error | Give the member a setter, or remove it from the data type | +| `CCSG132` | `ScheduleAsync` on another behaviour's `I{Behaviour}` — a timer only runs on the behaviour that scheduled it | error | Call `ScheduleAsync` only on the behaviour that owns the timer | +| `CCSG133` | A described `ICall` is never given `ScheduleAsync` or `ForScope` | error | Call `ScheduleAsync` or `ForScope` on the described `ICall` | +| `CCSG136` | Cloud Code service or generated `I{Behaviour}` parameter without `[CloudCodeService]` | error | Mark the parameter `[CloudCodeService]` | +| `CCSG142` | `[SyncView]` method that isn't `public` or `internal` | error | Make the `[SyncView]` method `public` or `internal` | +| `CCSG127` | `Scope.MultiplayerSession` client assembly missing `Unity.Services.CloudBehaviours.Multiplayer.Runtime` / `Unity.Services.Multiplayer` references — the message names both. Fires even with the package installed | error | Add the two assembly references the message names to the client assembly | +| `CCSG118` | Members of different kinds sharing a name — all of them dropped | error | Rename the members so no two kinds share a name | +| `CCSG117` | Duplicate member name within a behaviour — first declaration kept | **warning** | Rename the duplicate member | +| `CCSG119` | Member name reserved by the generated bindings class | **warning** | Rename the member away from the bindings class name | +| `CCSG113` | Read-only synchronized member ignored: a `readonly` field, or a get-only or computed property | **warning** | Give the member a setter if it must synchronize | +| `CCSG158` | `public static` or `const` member on a behaviour — not synchronized | **warning** | Move the value to a synchronized instance member if clients need it | +| `CCSG114` | Unsupported event handler type, including more than four payload parameters | **warning** | Use a supported handler type with at most four payload parameters | + +The warnings matter as much as the errors: each one means a member you wrote is **not** on the wire, and +the build still succeeds. If a client member is missing, search the Console for `CCSG1` before changing +any code. + +## Silent failure modes + +These compile and then do nothing, which is what makes them expensive: + +- **Read-only and static members don't synchronize.** Public fields and public properties with a + setter of any accessibility sync. A `readonly` field or a get-only or computed property raises warning + `CCSG113`; a `public static` or `const` member raises warning `CCSG158`. Either way the client property never appears. +- **Names collide across kinds, case-insensitively.** Endpoints, synchronized fields, events and + projections share one wire-key namespace, and overloads collide for the same reason. A same-kind + duplicate keeps the first declaration with **warning** `CCSG117`, so the build stays green while the + dropped member is missing on the client. A cross-kind clash drops all of them and fails with error `CCSG118`. +- **Push events are never replayed**, so a late joiner sees nothing; lasting state belongs in a + synchronized field. +- **Player-scoped behaviours push to no other clients.** State reaches that one client through RPC + returns, the hydrate on connect, and `Hydrate()`. +- **A module without a `.ccmu` never reaches the Deployment window.** Hand-written asmdefs compile and + generate bindings, but nothing lists the module for deployment and nothing warns. Create new modules + through the menu, and repair an existing one by adding its `.ccmu` (see + [Creating the module](#creating-the-module)). +- **Model types are data only.** Methods and constructors are not regenerated on the client — the stub + carries the public fields and properties, nothing else. A computed or get-only property is error + `CCSG131`. + +When a member is missing on the client, read the console for generator diagnostics before changing code. diff --git a/skills/build-live-game/references/cloud-code-modules.md b/skills/build-live-game/references/cloud-code-modules.md new file mode 100644 index 0000000..e755838 --- /dev/null +++ b/skills/build-live-game/references/cloud-code-modules.md @@ -0,0 +1,1276 @@ +# Cloud Code Scripts and Modules Reference + +Covers **Cloud Code modules** (`.ccmu`, authored natively in the Editor), **legacy modules** (`.ccmr` +references to a .NET solution built outside Unity) and JavaScript **scripts**. For CloudBehaviours, see the CloudBehaviours reference. + +> **On Unity 6000.5+, create new C# modules as `.ccmu`.** They are authored inside the Unity project, compile with it, and +> generate their client bindings at compile time — no separate solution, no `dotnet publish`. `.ccmr` is +> legacy: on 6000.5+ maintain the ones a project already has and do not create new ones; on an older +> Editor, new modules use `.ccmr`. On 6000.5+, if you are about to write a +> `.sln`, a `.csproj` or a `FolderProfile.pubxml`, you are on the wrong path — see +> [Creating a Cloud Code Module](#creating-a-cloud-code-module-ccmu) instead. + +> **The signatures, enums and diagnostics below are a convenience snapshot and can lag the package.** +> Read the [Cloud Code documentation](https://docs.unity.com/en-us/cloud-code/modules/overview.md) for +> the project's installed `com.unity.services.cloudcode` version before acting, and again whenever a +> step fails or a symbol does not resolve. + +## Table of Contents + +- [Anti-Hallucination Reference](#anti-hallucination-reference) +- [ICloudCodeService Methods](#icloudcodeservice-methods) +- [Module Runtime Constraints](#module-runtime-constraints) +- [Subscriptions](#subscriptions) +- [Triggers](#triggers) +- [Creating a Cloud Code Module (.ccmu)](#creating-a-cloud-code-module-ccmu) + - [Injected Services](#injected-services-cloudcodeservice) + - [Registering Services](#registering-services-cloudcloudsetupcs) +- [Module State and Persistence](#module-state-and-persistence) + - [Evolving a persisted class](#evolving-a-persisted-class) +- [Generated Client Bindings](#generated-client-bindings) +- [Legacy Modules (.ccmr) - Maintenance Only](#legacy-modules-ccmr---maintenance-only) +- [Code Templates](#code-templates) +- [Deploying via the Deployment Window](#deploying-via-the-deployment-window) +- [Running the Local Cloud Code Server](#running-the-local-cloud-code-server) +- [Error Handling](#error-handling) +- [Error Messages](#error-messages) + +--- + +## Anti-Hallucination Reference + +| Correct | Incorrect (do NOT use) | +|---|---| +| For a new C# module, author a `.ccmu` in the Editor | Scaffolding a `.sln`/`.csproj`/`.pubxml` for a new module -- that is legacy `.ccmr` | +| Create a `.ccmu` module with **Create > Services > Cloud Code Module Script** | Hand-writing a new module's `.asmdef` files -- a module without a `.ccmu` never appears in the Deployment window | +| A `.ccmu` module's name is its **cloud asmdef** `name` | The `.ccmu` filename -- it is only a fallback | +| `.ccmu` client bindings are generated at **compile time** from `[CloudModuleClient]` | Clicking **Generate Bindings** -- that is the `.ccmr` flow | +| `[CloudCodeService]` on an injected parameter of a `.ccmu` endpoint | A bare one -- the server still injects it, but it leaks into the generated client binding | +| `config.AddGameApiClient()` in `ICloudCodeSetup` | `config.Dependencies.AddSingleton(GameApiClient.Create())` -- `Create()` is `[Obsolete]` | +| The generated client for a `.ccmu` module; `CallModuleEndpointAsync("Module", "Function", args)` for a legacy `.ccmr` module | `CallModuleEndpointAsync` on a `.ccmu` module, or `CallEndpointAsync` for modules | +| `CallEndpointAsync("scriptName", args)` | `CallEndpointAsync` for modules -- scripts only | +| `SubscribeToPlayerMessagesAsync()`, or the `(callbacks)` overload | `SubscribeToGeneralMessagesAsync` -- no such method | +| Pass a `SubscriptionEventCallbacks` to the subscribe call, or attach to `subscription.Callbacks` afterwards | Callbacks as a constructor argument -- there is no such constructor | +| Non-generic `CallEndpointAsync` returns `Task` | Returns `Task` (void) -- it returns a string | +| Non-generic `CallModuleEndpointAsync` returns `Task` | Returns `Task` (void) -- it returns a string | +| `EventConnectionState` enum | `SubscriptionEventConnectionState` | +| `Error` callback is `Action` | `Action` | + +--- + +## ICloudCodeService Methods + +```csharp +// Call a Cloud Code Script endpoint. Returns the script's return value as string. +Task CallEndpointAsync(string function, Dictionary args = default) + +// Call a Cloud Code Script endpoint with typed deserialization. +Task CallEndpointAsync(string function, Dictionary args = default) + +// Call a Cloud Code Module endpoint. Returns the result as string. +// module = module name, function = method name. +// Legacy .ccmr modules only: module = the .ccmr filename without extension. +// A .ccmu module is called through its generated client, not with this method. +Task CallModuleEndpointAsync(string module, string function, + Dictionary args = default, CloudCodeScope scope = default) + +// Call a Cloud Code Module endpoint with typed deserialization. +Task CallModuleEndpointAsync(string module, string function, + Dictionary args = default, CloudCodeScope scope = default) + +// Subscribe to real-time messages sent to the current player. Requires Wire. +// Either wire callbacks up afterwards on the returned .Callbacks, or pass them in. +Task SubscribeToPlayerMessagesAsync() +Task SubscribeToPlayerMessagesAsync(SubscriptionEventCallbacks callbacks) + +// Subscribe to real-time project-wide messages. Requires Wire. Same two forms. +Task SubscribeToProjectMessagesAsync() +Task SubscribeToProjectMessagesAsync(SubscriptionEventCallbacks callbacks) +``` + +**CRITICAL:** The first argument to `CallModuleEndpointAsync` is the **module name**, the second is the **function name**. Do NOT confuse the parameter order. + +--- + +## Module Runtime Constraints + +- **Cold start:** An idle module's worker is eventually shut down, and the next call after that pays a latency spike while it spins back up. The idle window is a service-side setting rather than a documented guarantee, so design the UX to tolerate a slow first call rather than coding to a specific number. +- **No UnityEngine:** Modules run on standard .NET, not in the player. The `UnityEngine` namespace is NOT + available. A `.ccmu` cloud assembly is Editor-only and compiled by Unity, so its dependencies must be + available to the Editor; a `.ccmr` solution can pull NuGet packages directly. +- **Push messages:** Server-to-client push messages can only be sent from **modules**, not from JavaScript scripts. +- **.NET version:** modules target .NET 8 on Unity before 6.7 and on the local Cloud Code server; on + Unity 6.7+ they can target .NET 10. Legacy `.ccmr` solutions support up to .NET 10. + +--- + +## Subscriptions + +Cloud Code Subscriptions require `com.unity.services.wire`. Subscribe to real-time server-push messages. + +### `ISubscriptionEvents` + +```csharp +public interface ISubscriptionEvents +{ + SubscriptionEventCallbacks Callbacks { get; } // Wire up handlers here + Task SubscribeAsync(); // Re-subscribe after unsubscribing + Task UnsubscribeAsync(); // Stop receiving messages +} +``` + +### `SubscriptionEventCallbacks` + +There are two forms. **Prefer building the callbacks first and passing them in**, so every handler is +attached before the subscription goes live: + +```csharp +var callbacks = new SubscriptionEventCallbacks(); +callbacks.MessageReceived += OnMessageReceived; // Action +callbacks.MessageBytesReceived += OnBytesReceived; // Action +callbacks.ConnectionStateChanged += OnStateChanged; // Action +callbacks.Kicked += OnKicked; // Action +callbacks.Error += OnError; // Action + +var subscription = await CloudCodeService.Instance.SubscribeToPlayerMessagesAsync(callbacks); +``` + +The parameterless overload is a convenience wrapper that calls the same method with a fresh, empty +`SubscriptionEventCallbacks`. You then attach handlers to the returned object's `Callbacks` property — +which means a message arriving between the subscription going live and your `+=` running has no handler to +reach: + +```csharp +var subscription = await CloudCodeService.Instance.SubscribeToPlayerMessagesAsync(); +subscription.Callbacks.MessageReceived += OnMessageReceived; // anything before this line is missed +``` + +Both overloads exist for `SubscribeToProjectMessagesAsync` too. + +### `IMessageReceivedEvent` Properties + +| Property | Type | Description | +|---|---|---| +| `MessageType` | `string` | Message type identifier set by the server | +| `Message` | `string` | Raw message payload (typically JSON) | +| `Id` | `string` | Unique message ID | +| `CorrelationId` | `string` | Correlation ID for tracing | +| `Source` | `string` | Source of the message | +| `Type` | `string` | CloudEvents type | +| `SpecVersion` | `string` | CloudEvents spec version | +| `Time` | `DateTime` | Timestamp | +| `ProjectId` | `string` | UGS project ID | +| `EnvironmentId` | `string` | UGS environment ID | + +### `EventConnectionState` + +Namespace: `Unity.Services.CloudCode.Subscriptions`. + +| State | Value | Meaning | +|---|---|---| +| `Unknown` | 0 | Initial state | +| `Unsubscribed` | 1 | Not connected | +| `Subscribing` | 2 | Connection in progress | +| `Subscribed` | 3 | Connected and receiving messages | +| `Unsynced` | 4 | Temporarily disconnected, will reconnect | +| `Error` | 5 | Connection error | + +--- + +## Triggers + +Triggers let Cloud Code endpoints run automatically in response to events from other UGS services, without any client call. Configured in the Unity Dashboard, the Deployment Window (`.tr` files, via `com.unity.services.tooling`), or `unity ugs tr` (see `references/ugs-cli.md`) -- not in client code. + +Which services emit trigger events, and under what event names, is service-side configuration rather than +anything in this SDK — it changes independently of the package. Treat the following as illustration and +confirm the current set in the Dashboard's trigger editor, which lists what is actually available to your +project: + +| Emitter | Example trigger events | +|---|---| +| Authentication | Player signed in, account deleted | +| Scheduler | Cron-based or one-shot scheduled events | +| Leaderboards | Score submitted, leaderboard reset | +| Cloud Save | Data saved, data deleted | + +--- + +## Creating a Cloud Code Module (`.ccmu`) + +A Cloud Code module is authored **inside the Unity project**. Its cloud code lives in an Editor-only +assembly that Unity compiles with the rest of the project, and the client bindings are generated at +compile time. There is no solution to build, nothing to publish, and no NuGet restore. + +### Requirements + +- Unity **6000.5** or newer. +- `com.unity.services.cloudcode` **3.0.0** or newer. +- **Api Compatibility Level set to `.NET Standard 2.1`** in Player Settings. Any other level makes the + module compile fine and then fail to deploy with "Unsupported API Compatibility Level". + +### Create it + +Right-click a folder under `Assets/` -> **Create > Services > Cloud Code Module Script** and fill in the +creation window. An agent without computer-use tools asks the user to run this menu step; with them, it +runs the step itself. The rest of this section describes what the Editor generates for you, so you can +recognise and check the result. It is not a list of files to write by hand. + +Always use the menu for a new module, because it writes the module's `.ccmu` asset along with both +assemblies, and the Deployment window lists only modules that have a `.ccmu`. A module built from +hand-written asmdefs alone compiles and generates bindings, but never appears in the Deployment window, +and nothing reports why. + +If an existing module is missing from the Deployment window, check for its `.ccmu`. When there is none, +repair the module by adding one rather than rebuilding it, because that keeps every assembly name, GUID +and script reference: + +1. Stop until the user confirms a rollback point, such as a commit in their version control or a backup + copy of the project. +2. Write `.ccmu` in the folder that holds both assembly folders, following + [`.ccmu` File Format](#ccmu-file-format). +3. Verify: the `.ccmu` imports with zero console errors, and the module is listed in + **Services > Deployment** under the cloud asmdef's name. If either check fails, restore the rollback + point and read the console error before trying again. + +### Directory Structure + +The module is a folder named after the module. The `.ccmu` sits at its root, one level **above** both +assemblies: + +``` +Assets// + MyModule/ + MyModule.ccmu <-- the module asset + Cloud/ + MyModule.asmdef <-- Editor-only cloud assembly; its name IS the module name + MyCloudCodeScript.cs <-- [CloudCodeFunction] entry points + CloudSetup.cs <-- you add this; only if an endpoint injects IGameApiClient + Client/ + MyModuleClient.asmdef <-- ships in the player; must reference the cloud assembly + MyCloudCodeScriptClient.cs <-- [CloudModuleClient] partial; bindings generated into it +``` + +The module folder can live anywhere under `Assets/`. **No `~` suffix** -- unlike `.ccmr`, the cloud +assembly is meant to be compiled by Unity, so hiding it from the importer would break the module. + +This is the layout the Editor creates. Keep it, and in particular **leave the `.ccmu` in the folder it was +created in**. The Editor treats a folder holding a `.ccmu` as one that already contains a module, so moving +the file can allow a second module to be created on top of this one. If a project already keeps its `.ccmu` +somewhere else, leave that alone rather than "fixing" it. + +### `.ccmu` File Format + +Plain JSON, two GUIDs, nothing else. It carries no code, no name and no path -- it exists only to pair the +two assemblies. The create menu writes it for a new module; write it by hand only to repair an existing +module that lacks one: + +```json +{ + "cloudAssemblyDefinitionGuid": "", + "clientAssemblyDefinitionGuid": "" +} +``` + +**Never invent these values.** They are Unity asset GUIDs: Unity mints one per asset on import and stores +it in that asset's `.meta` file. Copy each one out of the `guid:` line of the matching `.asmdef.meta` -- +the cloud assembly's for `cloudAssemblyDefinitionGuid`, the client assembly's for +`clientAssemblyDefinitionGuid`: + +```yaml +# Cloud/MyModule.asmdef.meta +fileFormatVersion: 2 +guid: +``` + +A GUID identifies an asset in one project only, so a value taken from anywhere else -- another project, +another module, a sample, an example in documentation -- resolves to nothing. Both keys are required; a +`.ccmu` naming a GUID Unity cannot resolve fails to import and logs an error. + +Creating the module through the Editor writes the file with the right values already in it. + +### Deployed Module Name + +**The module name is the cloud assembly's `name` field, not the `.ccmu` filename.** That is the name the +generated bindings pass to `CallModuleEndpointAsync` and the name the module deploys under. The filename +is used only as a fallback, when the asmdef cannot be read. + +The creation window keeps the three in sync: module name `MyModule` produces `MyModule.ccmu`, +`MyModule.asmdef` (cloud) and `MyModuleClient.asmdef` (client). **Leave those names as generated.** Renaming +a module means renaming the cloud assembly, and that changes the deployed module name and every call site +along with it. + +### Cloud Assembly (`Cloud/MyModule.asmdef`) + +Editor-only, no engine references, not auto-referenced. It must reference Cloud Code Core and Apis; the +source generator reference is what emits the client bindings. + +```json +{ + "name": "MyModule", + "rootNamespace": "", + "references": [ + "Unity.Services.CloudCode.Apis", + "Unity.Services.CloudCode.Core", + "Unity.Services.CloudCode.SourceGenerator" + ], + "includePlatforms": [ "Editor" ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [ "UNITY_6000_5_OR_NEWER" ], + "versionDefines": [], + "noEngineReferences": true +} +``` + +`includePlatforms: ["Editor"]`, `noEngineReferences: true` and `autoReferenced: false` all matter: they keep +server-only code out of player builds, out of every other assembly's global scope, and away from +`UnityEngine` types that do not exist on the Cloud Code host. + +**Nothing validates them at import or deploy time, though.** Get one wrong and the module still imports, +still compiles, and still deploys — it is simply wrong in ways you find out later. The checks that do exist: + +| When | What is checked | How it surfaces | +| :-- | :-- | :-- | +| `.ccmu` import | only that both asmdef GUIDs resolve | console error, asset fails to import | +| **Create > Services > Cloud Code Module Script** into an existing module's `Cloud/` folder | Editor-only, no engine references, not auto-referenced, references Core + Apis | modal dialog: *"This Cloud Code module's server Assembly Definition (.asmdef) is missing or misconfigured..."*, and the script is not created | +| Deploy | nothing about the asmdef flags; the only structural check is that two modules do not share a cloud assembly | per-item error status on each offending module in the Deployment window, logged as *"Assembly Failure -- Multiple CCMs reference the same assembly definition"* | + +So the asmdef flags are effectively enforced only when you add a script through the menu. A hand-edited +module is on the honour system — worth checking by eye, because nothing else will. (Deploy +does make other checks unrelated to the asmdef — Api Compatibility Level and whether the project compiles — +covered under [Deploying via the Deployment Window](#deploying-via-the-deployment-window).) + +### Client Assembly (`Client/MyModuleClient.asmdef`) + +This is the assembly that ships in your game. It **must reference the cloud assembly**, which looks wrong at +first glance: the cloud assembly is Editor-only, so how can a runtime assembly that gets compiled into a +player depend on it? + +- **In the Editor**, the reference is what lets the source generator see the cloud class and read its + `[CloudCodeFunction]` methods, and what makes `[CloudModuleClient(typeof(MyCloudCodeScript))]` resolve. + Without it there is nothing to generate bindings from. +- **In a player build**, the Editor-only cloud assembly is not linked — so the generator emits an **empty + stub type** for each bound cloud class, purely so the `typeof(...)` in the attribute still resolves. The + stub carries no members. + +The upshot: your server code never ships to players. What ships is the generated bindings plus an empty +shell of the cloud class name. You do not write or manage the stubs; they exist only in non-Editor +compilation. + +The asmdef itself is an ordinary runtime one — no `includePlatforms`, engine references allowed: + +```json +{ + "name": "MyModuleClient", + "rootNamespace": "", + "references": [ + "MyModule", + "Unity.Services.CloudCode", + "Unity.Services.CloudCode.SourceGenerator" + ], + "includePlatforms": [], + "excludePlatforms": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} +``` + +### Cloud Script (`Cloud/MyCloudCodeScript.cs`) + +A plain class — no `.csproj`, no publish profile. + +```csharp +using System.Threading.Tasks; +using Unity.Services.CloudCode.Core; + +namespace MyModule.Cloud +{ + // The scope determines how state is persisted. See Module State and Persistence below. + [StateScope(Scope.Player)] + public class MyCloudCodeScript + { + [CloudCodeFunction(nameof(SayHello))] + public string SayHello(string name) => $"Hello, {name}!"; + + [CloudCodeFunction(nameof(GetScoreAsync))] + public async Task GetScoreAsync() => await Task.FromResult(1); + } +} +``` + +The generated namespaces derive from the **cloud** assembly name: `.Cloud` for cloud scripts +and `.Client` for client scripts. + +### Injected Services (`[CloudCodeService]`) + +A module gets its services in one of two ways — constructor injection and marked method parameters. Both +are supported and both generate correct client bindings; the one exception is `IExecutionContext`, which +must always be a method parameter (see below). + +**Constructor injection.** The class takes its services in a constructor; the runtime resolves them from the +DI container. The class must have **exactly one public constructor**, or instantiation fails with +`Invalid constructor: type X must have exactly one public constructor`. Constructor parameters are not +endpoint parameters, so they never reach the generated client: + +```csharp +public class Rewards +{ + readonly IGameApiClient _client; + public Rewards(IGameApiClient client) => _client = client; + + [CloudCodeFunction(nameof(Grant))] + public Task Grant(string rewardId) { ... } // client sees: Grant(string rewardId) +} +``` + +**Method-parameter injection.** The service is a parameter on the endpoint itself. Here — and *only* here — +it must carry `[CloudCodeService]`: + +```csharp +[CloudCodeFunction(nameof(Grant))] +public Task Grant([CloudCodeService] IGameApiClient client, string rewardId) { ... } +``` + +The attribute tells the generator "this is injected, not caller data". It has no bearing on constructor +parameters. + +**Mark every injected parameter.** An unmarked parameter is treated as caller data: it lands in the +generated client as an argument the caller has to supply, and is sent in the request payload. That is +almost never what you want for a service — `IExecutionContext` in particular is a server-side interface +the client cannot construct, so the generated client is unusable. No diagnostic fires; an unexplained +error inside a generated `.g.cs` is the signal. + +**`IExecutionContext` must be a `[CloudCodeService]` method parameter** — a constructor asking for it +fails. Other services are fine in the constructor, and that is the tidier form when every endpoint uses +them. Keep per-invocation values out of the constructor: the instance is cached per scope, so whatever it +captures outlives a single call. + +The runtime provides these without any registration: + +| Service | Gives you | +| :-- | :-- | +| `IExecutionContext` | player ID, project ID, environment, service token, session | +| `ILogger` | logging | +| `ITimerService` | register and cancel server-owned timers that call back into the module | +| `IPushClient` | send push messages to a player or the whole project | + +Anything else — `IGameApiClient` above all — must be registered in `CloudSetup.cs` (below) before it can be +injected. Private helper methods are ordinary C# and need no attribute; only `[CloudCodeFunction]` +endpoints do. + +### Registering Services (`Cloud/CloudSetup.cs`) + +A class implementing `ICloudCodeSetup` registers what `[CloudCodeService]` can inject. Required once per +module if any endpoint injects `IGameApiClient`; omit the file entirely if none do. + +```csharp +using Unity.Services.CloudCode.Apis.Extensions; +using Unity.Services.CloudCode.Core; + +namespace MyModule.Cloud +{ + public class CloudSetup : ICloudCodeSetup + { + public void Setup(ICloudCodeConfig config) + { + config.AddGameApiClient(); + } + } +} +``` + +Use the `config.AddGameApiClient()` extension. The older +`config.Dependencies.AddSingleton(GameApiClient.Create())` still appears in legacy `.ccmr` modules, but +`GameApiClient.Create()` is `[Obsolete]` — its own message directs you to `AddGameApiClient()`. + +### Client Script (`Client/MyCloudCodeScriptClient.cs`) + +An empty `partial` class carrying `[CloudModuleClient(typeof(CloudClass))]`. The source generator fills in +the other half at compile time. Do not add members here -- they will collide with the generated partial. + +```csharp +using Unity.Services.CloudCode; + +namespace MyModule.Client +{ + [CloudModuleClient(typeof(global::MyModule.Cloud.MyCloudCodeScript))] + public partial class MyCloudCodeScriptClient + { + } +} +``` + +### Adding Another Script to an Existing Module + +Right-click the module's **`Cloud/` folder** -> **Create > Services > Cloud Code Module Script**. The same +menu item detects the existing module and creates the cloud/client pair inside it instead of starting a +new module. Creating a script in the `Client/` folder is rejected -- always start from `Cloud/`. + +--- + +## Module State and Persistence + +A module class annotated `[StateScope]` keeps state between calls. The runtime holds one instance per +scope — per player, or per multiplayer session — and every change to a persisted member is saved for you. +Saving is guaranteed, but not necessarily complete by the time a call returns, so do not treat "the call +finished" as "the state is on disk". + +```csharp +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Unity.Services.CloudCode.Core; + +[StateScope(Scope.MultiplayerSession)] +public class TurnTimer +{ + public bool GameOver; // public members persist + public int TurnCount; + public List History = new(); + + [CloudCodeSerializeProperty] + private string _secretAnswer; // private, but persisted + + [CloudCodeIgnoreProperty] + public int CallsThisSession; // public, but not persisted + + readonly ILogger _logger; // not persisted; injected by the constructor below + + // Services are injected through the constructor or a [CloudCodeService] parameter -- never into a + // bare field. A field nothing assigns stays null. + public TurnTimer(ILogger logger) => _logger = logger; + + [CloudCodeFunction("PlayTurn")] + public void PlayTurn(string move) { TurnCount++; } +} +``` + +| Scope | Instance per | Typical use | +| :-- | :-- | :-- | +| `Scope.Player` | player | inventory, progression, per-player counters | +| `Scope.MultiplayerSession` | session | turn order, shared board, match state | +| *(no `[StateScope]`)* | none — stateless | validation, rewards, calls to other services | + +### Targeting an instance from the client + +A `.ccmu` client targets an instance through its constructor: the generated client takes the player ID or the session ID that its cloud class's `[StateScope]` needs (see [Generated Client Bindings](#generated-client-bindings)), and passes the matching `CloudCodeScope` for you. + +```csharp +// Scope to a multiplayer session: the generated client takes the session ID +var client = new TurnTimerClient(sessionId); +await client.PlayTurn(move); +``` + +`CloudCodeScope` and `ScopeType` (namespace `Unity.Services.CloudCode`) are what the generated client sends on your behalf. + +#### `ScopeType` Enum + +| Scope Type | Value | Description | +|---|---|---| +| `MultiplayerSession` | 1 | Scoped to a multiplayer session ID | +| `Player` | 2 | Scoped to the calling player | + +**There is no default scope.** The `scope` argument defaults to `null`, which is sent as "no scope", not as +`Player`. A function on a `[StateScope]` class called without a scope is rejected server-side with +*"Function's ... class in module ... requires a scope and must be called with a scope"*. Conversely a +non-scoped function called *with* a scope is rejected too, and a mismatched scope +(`Player` against a `[StateScope(Scope.MultiplayerSession)]` class) fails the same way. The generated client +gets this right on its own — it passes the scope its cloud class declares — which is one more reason to always call a `.ccmu` module through it. + +--- + + +### What persists + +- **public members persist** — fields and properties alike +- `[CloudCodeSerializeProperty]` persists a non-public member +- `[CloudCodeIgnoreProperty]` excludes a public one +- anything else — plain private fields — is not persisted + +**Accessibility decides this, not what the member holds.** The serializer asks only: is it ignored, is it +explicitly marked, is it public? There is no exemption for service types. So a service or logger assigned to +a **public** field *is* persisted, with two consequences worth avoiding: + +- Its object graph is walked and written into your scope state — which for `IExecutionContext` means the + service token, and for `IGameApiClient` means client configuration. +- Persisted members are written back when state is loaded, so the value the constructor injected is overwritten by + whatever came out of storage — and an interface cannot be reconstructed from the stored JSON. + +Keep injected services and loggers in **private** fields, which are excluded by default: + +```csharp +readonly IGameApiClient _client; // private: not persisted +readonly ILogger _logger; +``` + +If one genuinely has to be public, exclude it explicitly: + +```csharp +[CloudCodeIgnoreProperty] +public IGameApiClient Client; // public, but excluded +``` + +`[CloudCodeIgnoreProperty]` is checked before `[CloudCodeSerializeProperty]`, so ignore always wins. + +"Not persisted" is not the same as "reset every call". The runtime keeps one instance per scope and can +reuse it across invocations. Do not build on that: a non-persisted field may hold its value across +invocations but can be discarded at any point. Keep anything that must survive in a persisted member. + +### Custom serialization + +Implementing `IStateSerializer` on the module class replaces the default serialization entirely: + +```csharp +public byte[] OnSerialize(); +public void OnDeserialize(byte[] input); +``` + +The runtime checks the module instance for the interface and uses it when present, so this works because +the class you author is the class that gets deployed. Reach for it when the default serialization is +unworkable — large binary blobs, custom compression, or a schema you want to version by hand. + +### Evolving a persisted class + +Persisted state outlives the code that wrote it. A deployed module reads records written by every +earlier version of itself, so the shape of a `[StateScope]` class is a storage contract, not just a +class declaration. Nothing warns you: the module compiles, deploys and runs, and the damage shows up +later as a value that is gone, a list that keeps growing, or a scope that no longer loads. + +| Change to a persisted class | What happens to already-persisted records | +| :-- | :-- | +| **Add** a member | Keeps its field initializer (or the value the constructor set). Loading only overwrites members that are present in the stored record. | +| **Rename** a member | Treated as a remove plus an add: the stored value is stranded and the new member keeps its initializer. | +| **Remove** a member | Value is dropped on the next write. | +| **Retype** a member | A compatible stored value converts (a number into a `string` field). An incompatible one (`"abc"` into an `int`, an object into a number) throws on load, and every call for that scope fails with `CloudCodeExecutionScopeStateException` until the state is fixed. | +| **Rename the class** | Instance state is keyed on the class name, so a rename orphans that scope's entire state — every field, not one. | +| **Change the `Scope`** | Different instance key. The old state is unreachable. | + +**Give a new member the initializer you want; it applies to old records.** A new `bool AllowSpectators = true;` +reads `true` for every player or session that existed before the change, because the instance is +constructed first and the stored record only overwrites the members it contains. + +**Initialize persisted collections empty.** Loading adds stored items to the collection the +initializer already created, so `public List Tags = new() { "starter" };` gains another +`"starter"` on every load and save. A `HashSet` or `Dictionary` does not duplicate, but its +initializer entries come back after the player's state removed them. Declare persisted collections +as `= new();` and add starting items from code the first time the state is set up. + +**Design the state so migration stays cheap:** + +- Keep the persisted surface small and explicit. Private fields plus `[CloudCodeIgnoreProperty]` on + anything derived or cached means fewer members that have to be migrated later. +- Put game state in one payload class, versioned as a whole, rather than a wide set of loose fields + on the module class. +- Never reuse a member name for a new meaning, and never renumber or reorder an enum that is + persisted — the stored value is what comes back. +- Do not hide the real schema inside a hand-serialized string. A single opaque `string` field keeps + every one of these problems and moves them into your own serializer. + +**Migrate with a version envelope and a chain of forms.** Implement `IStateSerializer`, stamp a +schema version alongside the payload, and let each version own the single hop to the next. A module +never writes a migration between two arbitrary versions: it deserializes whichever form was +persisted and calls `Migrate` until it reaches the latest. + +```csharp +using System; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Unity.Services.CloudCode.Core; + +public interface ISchema +{ + ISchema Migrate(); // next form, or null when this is the latest +} + +public class SchemaV1 : ISchema +{ + public string Name = ""; + public ISchema Migrate() => new SchemaV2 { CharacterName = Name }; +} + +public class SchemaV2 : ISchema +{ + public string CharacterName = ""; + public ISchema Migrate() => null; +} + +[StateScope(Scope.Player)] +public class PlayerProfile : IStateSerializer +{ + const int SchemaVersion = 2; + + public SchemaV2 Data = new(); + + public byte[] OnSerialize() + { + var envelope = new JObject + { + ["SchemaVersion"] = SchemaVersion, + ["Data"] = JObject.FromObject(Data), + }; + return Encoding.UTF8.GetBytes(envelope.ToString(Formatting.None)); + } + + public void OnDeserialize(byte[] input) + { + // Nothing stored for this scope -- keep the instance as constructed. + if (input == null || input.Length == 0) + return; + + var envelope = JObject.Parse(Encoding.UTF8.GetString(input)); + var version = envelope["SchemaVersion"]?.Value() ?? 1; + // A record written before this serializer existed is the bare v1 member set, with no envelope. + var payload = envelope["SchemaVersion"] == null ? envelope : (JObject)envelope["Data"]; + + ISchema schema = version switch + { + 1 => payload.ToObject(), + 2 => payload.ToObject(), + // State written by a newer version cannot be migrated backwards, and running on a + // default payload would silently discard the player's data. + _ => throw new Exception($"cannot read schema version {version}, this module writes {SchemaVersion}"), + }; + + while (schema?.Migrate() is { } next) schema = next; + + Data = schema as SchemaV2 ?? throw new Exception($"chain from version {version} did not end at SchemaV2"); + } +} +``` + +`JObject` needs `Newtonsoft.Json.dll` in the **cloud** asmdef's `precompiledReferences`. The generated +asmdef ships that array empty with `overrideReferences: true`, so the reference has to be added by hand — +without it the `Newtonsoft.Json` namespace does not resolve in the module assembly at all: + +```json + "overrideReferences": true, + "precompiledReferences": [ "Newtonsoft.Json.dll" ], +``` + +Adding a `SchemaV3` later means writing `SchemaV2.Migrate`, bumping `SchemaVersion`, adding a `3` case +to the switch and changing both the `Data` field type and the final type check to `SchemaV3`. The +earlier hops do not change. The default serializer stores the persisted members as a JSON object +keyed by member name, so a record written before the envelope existed is that bare v1 payload. Keep the old +`SchemaV*` classes — deleting one breaks every record still stored in that form. A hop is also where +a value is repaired rather than defaulted: a list that did not exist in v1, a cosmetic an older +record claims the player owns. + +**Verify against what is actually stored, not against the class.** Before changing a deployed +`[StateScope]` class, look at what real records hold. There is no API or CLI that reads a module's +server-side state, so add a temporary read-only `[CloudCodeFunction]` that returns only the members +you need to check, deploy it to a non-production environment, call it once for a known player or +session, and remove it afterwards. A deployed function is callable by clients, so never return +secret state such as `_secretAnswer`. This shows the values after deserialization, not the stored +format. + +## Generated Client Bindings + +`.ccmu` and `.ccmr` both give you a strongly-typed client instead of raw `CallModuleEndpointAsync`, but +they generate it in completely different ways. Do not mix the two flows. + +### `.ccmu` -- generated at compile time + +You do not write either half of this. The creation flow generates the cloud script and its client partial, +and the client partial already carries `[CloudModuleClient(typeof(CloudClass))]`. From there the source +generator emits `.g.cs` on every compile. Nothing to click, nothing to deploy first, no +`GeneratedModuleBindings/` folder. + +The constructor depends on the cloud class's `[StateScope]`: + +| Cloud class | Constructor | +| :-- | :-- | +| `[StateScope(Scope.Player)]` | `new MyScriptClient(playerId)` | +| `[StateScope(Scope.MultiplayerSession)]` | `new MyScriptClient(sessionId)` | +| *(no `[StateScope]`)* | `new MyScriptClient()` | + +Each also has an overload taking an `ICloudCodeService`, for tests or a non-default instance. + +```csharp +var client = new MyCloudCodeScriptClient(AuthenticationService.Instance.PlayerId); +var score = await client.GetScoreAsync(); +``` + +The generated methods return `Task`/`Task` and pass the **cloud assembly name** as the module name, so +a rename of that assembly changes the deployed endpoint the client calls. +An `Access.Service` function gets no client method, since only Cloud Code itself (timers, for example) +can call it; calling it from the client is a compile error, not a runtime `403`. + +**Types in endpoint signatures.** Declare a class or enum that an endpoint takes or returns once, in the cloud +assembly, and use that type from client code. The generator emits a data-only stub of it, with the same name and +namespace, into the client assembly, in the Editor and in player builds. The game assembly sees the stub through the client assembly and never references the Editor-only cloud assembly. A class is reduced to its public instance fields and properties that have both a public getter and a public setter, and each one is emitted on the stub as a `{ get; set; }` property. Get-only, computed, `readonly` and private-setter members, non-public members, indexers, statics, consts, methods, constructors and initializers are not copied. The generator warns only about public members it leaves out because they are read-only (CCSG216) or computed (CCSG229); non-public, static, const and indexer members are skipped silently. Declare a model type as plain data with public settable members, and do not declare a second copy on the client. A type from any other assembly is referenced directly and is not stubbed. + +### `.ccmr` -- generated on demand from the deployed module + +The generator reads the *deployed* module's metadata and writes one file per class under +`Assets/CloudCode/GeneratedModuleBindings/`. Deploy first, then generate. Do not edit those files -- they +are overwritten on regeneration. + +- Select the `.ccmr` in the **Project window**, then click **Generate Bindings** in the Inspector, or +- **Services > CloudCode > Generate All Modules Bindings** to regenerate every `.ccmr` at once. + +```csharp +// Assets/CloudCode/GeneratedModuleBindings/MyModuleBindings.cs +// This file was generated by Cloud Code Bindings Generator. Modifications will be lost upon regeneration. +public class MyModuleBindings +{ + readonly ICloudCodeService k_Service; + public MyModuleBindings(ICloudCodeService service) { k_Service = service; } + + public async Task SayHello(string name) + => await k_Service.CallModuleEndpointAsync("MyModule", "SayHello", + new Dictionary { { "name", name } }); +} +``` + +```csharp +var bindings = new MyModuleBindings(CloudCodeService.Instance); +var greeting = await bindings.SayHello("World"); +``` + +**Known limitations (`.ccmr`):** no distinction between required/optional parameters; tuples are not +supported. + +--- + +## Legacy Modules (`.ccmr`) - Maintenance Only + +**On Unity 6000.5+, do not create new `.ccmr` modules**; see +[Creating a Cloud Code Module](#creating-a-cloud-code-module-ccmu) instead. This section covers reading, +fixing and redeploying the `.ccmr` modules a project already has, and creating a new one on an older Editor, +which cannot author `.ccmu`. + +A legacy module is a .NET class library (.NET 8, up to .NET 10) living outside `Assets/`, built and published by the Deployment +Window, and referenced from Unity by a `.ccmr` file. + +### Directory Structure + +Place the module at the **project root** (alongside `Assets/`). Unity does not scan the project root, so no `~` suffix is needed. + +> **Tilde rule:** Only add `~` to a folder name (e.g. `MyModule~/`) when the module lives **inside `Assets/`** -- the suffix tells Unity to skip that folder during compilation. At the project root, omit it. + +``` +/ + Assets/ + CloudCode/ + MyModule.ccmr <-- Unity asset pointing to the .sln + MyModuleCCM/ + MyModule.sln + MyModule/ + MyModule.csproj + MyModule.cs <-- [CloudCodeFunction] entry points + ModuleSetup.cs <-- registers IGameApiClient via DI + Properties/ + PublishProfiles/ + FolderProfile.pubxml <-- publish to linux-x64 (required) + MyModule.Tests/ + MyModule.Tests.csproj <-- IsPublishable=false, never deployed + MyModuleTests.cs +``` + +### `.sln` File Template + +Generate fresh GUIDs for `{MAIN-GUID}`, `{TEST-GUID}`, and `{SLN-GUID}` using `[System.Guid]::NewGuid()` in PowerShell or any GUID generator. The test project intentionally has no `Release Build.0` entry to exclude it from Release builds. + +``` +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30114.105 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyModule", "MyModule\MyModule.csproj", "{MAIN-GUID}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyModule.Tests", "MyModule.Tests\MyModule.Tests.csproj", "{TEST-GUID}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {MAIN-GUID}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {MAIN-GUID}.Debug|Any CPU.Build.0 = Debug|Any CPU + {MAIN-GUID}.Release|Any CPU.ActiveCfg = Release|Any CPU + {MAIN-GUID}.Release|Any CPU.Build.0 = Release|Any CPU + {TEST-GUID}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {TEST-GUID}.Debug|Any CPU.Build.0 = Debug|Any CPU + {TEST-GUID}.Release|Any CPU.ActiveCfg = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {SLN-GUID} + EndGlobalSection +EndGlobal +``` + +### `.csproj` (net8.0, CloudCode.Apis 0.0.27, CloudCode.Core 0.0.7) + +```xml + + + + net8.0 + enable + Debug;Release + + + + + + + + + + true + + + +``` + +### `FolderProfile.pubxml` (linux-x64, SelfContained=false) + +Cloud Code runs on Linux. `SelfContained=false` keeps the output small -- the runtime is provided by the +Cloud Code host. + +```xml + + + + Release + Any CPU + bin\Release\net8.0\publish\ + FileSystem + net8.0 + linux-x64 + false + False + + +``` + +### `ModuleSetup.cs` (registers GameApiClient) + +Required once per module if any endpoint needs `IGameApiClient`. This is what the Editor's `.ccmr` +template generates: + +```csharp +using Unity.Services.CloudCode.Apis.Extensions; +using Unity.Services.CloudCode.Core; + +public class ModuleSetup : ICloudCodeSetup +{ + public void Setup(ICloudCodeConfig config) + { + config.AddGameApiClient(); + } +} +``` + +An older module may instead contain `config.Dependencies.AddSingleton(GameApiClient.Create())`. That still +works, but `GameApiClient.Create()` is `[Obsolete]` and its own message points at `AddGameApiClient()`, so +prefer the form above when you touch the file. + +### `MyModule.cs` ([CloudCodeFunction], IExecutionContext, IGameApiClient) + +```csharp +using System; +using System.Globalization; +using Microsoft.Extensions.Logging; +using Unity.Services.CloudCode.Apis; +using Unity.Services.CloudCode.Core; + +public class MyModule +{ + readonly IGameApiClient m_GameApiClient; + readonly ILogger m_Logger; + + public MyModule(IGameApiClient gameApiClient, ILogger logger) + { + m_GameApiClient = gameApiClient; + m_Logger = logger; + } + + // No context needed -- pure computation. + [CloudCodeFunction("SayHello")] + public string SayHello(string name) => $"Hello, {name}!"; + + // Add IExecutionContext as a method parameter only when you need player/project info. + [CloudCodeFunction("GetServerTime")] + public string GetServerTime(IExecutionContext context) + => DateTime.UtcNow.ToString(CultureInfo.InvariantCulture); +} +``` + +**Key concepts:** + +| File / Concept | Purpose | +|---|---| +| `[CloudCodeFunction]` | Marks a public method as a callable endpoint | +| `IExecutionContext` | Optional method parameter -- player ID, project ID, service token | +| `IGameApiClient` | Constructor-injected via DI -- typed clients for UGS APIs | +| `ModuleSetup.cs` | Registers `GameApiClient` via `ICloudCodeSetup` -- required once per module | +| `FolderProfile.pubxml` | Publish profile targeting `linux-x64` -- Cloud Code runs on Linux | +| `IsPublishable=false` | Prevents test project from being included in `dotnet publish` | + +**Dependency injection:** `IGameApiClient` and `ILogger` are constructor-injected. `IExecutionContext` is a method parameter (only add it when the function needs player/project info). + +### Test Project `.csproj` (IsPublishable=false) + +`IsPublishable=false` is enforced in two ways: this property, and the omission of `Release Build.0` in the `.sln`. Both are intentional. + +```xml + + + + net8.0 + latest + enable + enable + false + false + + + + + + + + + + + + + + + + + + + +``` + +### `.ccmr` File Format + +```json +{ + "modulePath": "../../MyModuleCCM/MyModule.sln" +} +``` + +Path is relative to the `.ccmr` file. `../../` goes up from `Assets/CloudCode/` to the project root, then into `MyModuleCCM/`. The filename without `.ccmr` (`MyModule`) is the module name used in `CallModuleEndpointAsync`. + +### Migrating a `.ccmr` to `.ccmu` + +Create a `.ccmu` module and move each `[CloudCodeFunction]` class into its `Cloud/` folder. `[StateScope]` +and the function bodies carry over unchanged, and so does constructor injection — with one exception. What +changes: + +| `.ccmr` | becomes | +| :-- | :-- | +| `.sln`, `.csproj`, `.pubxml`, test project | deleted -- Unity compiles the cloud assembly | +| constructor-injected `IGameApiClient` / `ILogger` | works as-is; the class needs exactly one public constructor | +| constructor-injected `IExecutionContext` | **must move** to a `[CloudCodeService]` method parameter -- it is a scoped service and cannot be constructor-injected here | +| bare `IExecutionContext` **method parameter** | same parameter, now marked `[CloudCodeService]` | +| `ModuleSetup.cs` with `GameApiClient.Create()` | `CloudSetup.cs` with `config.AddGameApiClient()` | + +NuGet `PackageReference`s do not survive either: a `.ccmu` compiles under Unity, so a dependency has to be +available to the Editor. Keep the module name stable (name the cloud asmdef after the old `.ccmr`) so the deployed module keeps its name, and replace every +`CallModuleEndpointAsync` call site with the new module's generated client. + +--- + +## Code Templates + +JavaScript scripts: maintain, deploy and call existing ones; write new server logic as a C# module. + +### Call a Script Endpoint + +```csharp +using Unity.Services.CloudCode; +using System.Collections.Generic; + +var args = new Dictionary +{ + { "level", 5 }, + { "difficulty", "hard" } +}; + +int reward = await CloudCodeService.Instance.CallEndpointAsync("CalculateReward", args); +Debug.Log($"Reward: {reward}"); +``` + +### Call a Cloud Code Module (`.ccmu`, generated client) + +```csharp +// Cloud class PlayerProgress is [StateScope(Scope.Player)], so the client takes a player ID. +var progress = new PlayerProgressClient(AuthenticationService.Instance.PlayerId); +var result = await progress.AwardXp(50); + +Debug.Log($"Total XP: {result.TotalXp}"); +``` + +### Call a Module Endpoint (Typed Result, raw) + +For legacy `.ccmr` modules only. A `.ccmu` module is always called through its generated client, as above. + +```csharp +// Module class name: "ProgressionModule", method: "AwardXp" +var result = await CloudCodeService.Instance.CallModuleEndpointAsync( + "ProgressionModule", "AwardXp", + new Dictionary { { "amount", 50 } }); +Debug.Log($"Total XP: {result.TotalXp}"); +``` + +### Subscribe to Player Messages (Full Example) + +```csharp +ISubscriptionEvents subscription; + +async Task SubscribeToMessages() +{ + // Attach every handler before subscribing, so nothing can arrive unhandled. + var callbacks = new SubscriptionEventCallbacks(); + callbacks.MessageReceived += OnMessageReceived; + callbacks.ConnectionStateChanged += state => + Debug.Log($"Subscription state: {state}"); + callbacks.Error += error => + Debug.LogError($"Subscription error: {error}"); + callbacks.Kicked += () => + Debug.LogWarning("Kicked from subscription"); + + subscription = await CloudCodeService.Instance.SubscribeToPlayerMessagesAsync(callbacks); +} + +void OnMessageReceived(IMessageReceivedEvent evt) +{ + Debug.Log($"[{evt.MessageType}] {evt.Message}"); + // var data = JsonUtility.FromJson(evt.Message); +} + +async Task Unsubscribe() +{ + await subscription.UnsubscribeAsync(); + subscription = null; +} +``` + +--- + +## Deploying via the Deployment Window + +1. Open **Services > Deployment** in the Unity Editor. +2. All `.js`, `.ccmu` and `.ccmr` files under `Assets/` are listed. **Select only what you mean to deploy** + -- deploying everything can overwrite modules that are not ready to go out. +3. A `.ccmu` deploys the compiled cloud assembly, under the **cloud asmdef's name**. Its client bindings + already exist -- they were generated when Unity compiled the module, so there is nothing to regenerate. +4. A `.ccmr` builds and publishes its solution. After it deploys, generate or regenerate its bindings + (see [Generated Client Bindings](#generated-client-bindings)). + +**The local Cloud Code server never deploys during play mode.** Entering play mode does not deploy modules to +it, and nothing is deployed to it while the Editor is in play mode. Edit and deploy in edit mode, then +enter play mode. Changes made during play mode do not reach the local server until you exit play mode and +deploy again. + +A `.ccmu` deploy fails with **"Unsupported API Compatibility Level"** unless the project's Player Settings +have Api Compatibility Level on `.NET Standard 2.1`. It also fails if the project has compile errors -- +the module is deployed from its compiled assembly, so it has to build first. + +**Deploying a `.ccmu` needs a running Editor.** Nothing packages a `.ccmu` outside the Editor, and +`unity ugs deploy` skips `.ccmu` (see `references/ugs-cli.md`), so a CI pipeline that deploys `.ccmu` modules has +to run the Editor. Only legacy `.ccmr` modules deploy without one, through their `.sln`; do not create a +`.ccmr` to get around this. + +**Local development needs no deploy.** While the local Cloud Code server runs (Cloud Code button on the +Editor toolbar > **Start Local Server**; it needs an active environment), each successful compile redeploys +changed `.ccmu` modules onto it, outside Play mode. **Nothing deploys to the local server during Play mode**, +not even an explicit deploy, which is refused: exit Play mode before recompiling and deploying. A +`404 function-not-found` for an endpoint added while playing means exactly this; exiting Play mode redeploys +it and logs `Local Cloud Code server reloaded`. + +**Known limitation.** An unfocused Editor doesn't notice file edits or run the local auto-deploy until it +regains focus. After editing module code without focusing the Editor, run `unity command recompile`, then +deploy with the `Deploy.cs` script from +`references/deployment.md`: while the local +server runs, the deploy targets it, not the environment. Stop the server before deploying remotely. + +To deploy without the window, run the deploy script in +the Trigger Deployment Programmatically section of `references/deployment.md`. + +--- + +## Running the Local Cloud Code Server + +The local server runs the project's modules in a process on this machine, and the SDK routes +`CallModuleEndpointAsync` to it instead of the cloud. Start and stop it with the Cloud Code button on the +Editor toolbar (**Start Local Server** / **Stop Local Server**), outside Play mode; it needs Unity 6000.3+. +Redeploy on compile, and what to do when the Editor is unfocused, are covered in +[Deploying via the Deployment Window](#deploying-via-the-deployment-window). + +The server listens on port **14750** by default. To run several Editors, each with its own local server, +give each project a distinct port in **Edit > Project Settings > Services > Cloud Code > Local Debugging > +Port** while its server is stopped; a port already in use fails startup with *"Server Port is not +available."* +Play mode reads the port and whether a server is running from Editor preferences shared by every Editor on +the machine, so calls go to the most recently started server, and stopping any Editor's server sends the +others' calls to the cloud. + +### It will not start until an active environment is set + +Startup validates the environment first, and a project with no active environment fails with: + +> Cannot start the local Cloud Code server. The environment has not been set. Please use the Environment +> Selector, found under Edit → Project Settings → Services → Environments, to set it. + +A fresh clone or a CI checkout has no active environment, so this is usually the first thing that fails. +Set one in **Edit > Project Settings > Services > Environments** (ask the user, or drive the Editor UI). + +--- + +## Error Handling + +```csharp +try +{ + var result = await client.MyFunction(arg); +} +catch (CloudCodeRateLimitedException ex) +{ + Debug.LogError($"Rate limited. Retry after {ex.RetryAfter}s"); +} +catch (CloudCodeException ex) +{ + Debug.LogError($"Cloud Code error: {ex.Message} (reason: {ex.Reason})"); +} +``` + +### `CloudCodeExceptionReason` Enum + +Read the `CloudCodeExceptionReason` API reference for the installed package before writing a handler that +branches on `ex.Reason`, because the set of reasons changes between package versions: + +`https://docs.unity3d.com/Packages/com.unity.services.cloudcode@/api/Unity.Services.CloudCode.CloudCodeExceptionReason.html` + +Take `` from the installed `com.unity.services.cloudcode` version in +`Packages/packages-lock.json` (for `3.0.0`, use `3.0`). That file lists the package even when it arrives as +a dependency of another package, which `Packages/manifest.json` does not. If a reason the code needs is missing from that page, the installed version does not +have it. + +--- + +## Error Messages + +| Error string | Cause | Fix | +| :-- | :-- | :-- | +| `Unsupported API Compatibility Level` | The project's Api Compatibility Level is not `.NET Standard 2.1` | Set it in Player Settings, then redeploy the `.ccmu` | +| `Invalid constructor: type X must have exactly one public constructor` | The module class has no public constructor or several | Keep exactly one public constructor | +| `Function's ... class in module ... requires a scope and must be called with a scope` | A `[StateScope]` function was called without a scope, or with the wrong one | Pass a `CloudCodeScope` that matches the class's `[StateScope]`, or use the generated client | +| `This Cloud Code module's server Assembly Definition (.asmdef) is missing or misconfigured...` | The cloud asmdef is not Editor-only, has engine references, is auto-referenced, or lacks the Core and Apis references | Fix the asmdef, then create the script again | +| `Assembly Failure -- Multiple CCMs reference the same assembly definition` | Two modules share one cloud assembly | Give each module its own cloud assembly | +| `Server Port is not available.` | Another process, often another Editor's local server, holds the port | Set a distinct port under Local Debugging > Port while the server is stopped | +| `Cannot start the local Cloud Code server. The environment has not been set...` | The project has no active environment | Set one in **Edit > Project Settings > Services > Environments** | +| `CloudCodeExecutionScopeStateException` | Persisted state no longer deserializes after an incompatible member change | Restore a compatible member type, or migrate with `IStateSerializer` | diff --git a/skills/build-live-game/references/cloud-code.md b/skills/build-live-game/references/cloud-code.md index 1884d2b..fc27180 100644 --- a/skills/build-live-game/references/cloud-code.md +++ b/skills/build-live-game/references/cloud-code.md @@ -2,18 +2,9 @@ ## Table of Contents -- [Anti-Hallucination Reference](#anti-hallucination-reference) -- [Two Execution Models](#two-execution-models) -- [ICloudCodeService Methods](#icloudcodeservice-methods) -- [Module Runtime Constraints](#module-runtime-constraints) -- [Subscriptions](#subscriptions) -- [Triggers](#triggers) -- [Module Scope](#module-scope) -- [Module Creation](#module-creation) -- [Generated Type-Safe Bindings](#generated-type-safe-bindings) -- [Code Templates](#code-templates) -- [Deploying via the Deployment Window](#deploying-via-the-deployment-window) -- [Error Handling](#error-handling) +- [Four Execution Models](#four-execution-models) + - [Cloud Code Modules vs CloudBehaviours](#cloud-code-modules-vs-cloudbehaviours) +- [Where the details live](#where-the-details-live) Accessed via `CloudCodeService.Instance` (`ICloudCodeService`, namespace `Unity.Services.CloudCode`). Assembly: `Unity.Services.CloudCode`. @@ -21,545 +12,47 @@ Call `UnityServices.InitializeAsync()` from `com.unity.services.core` and sign i --- -## Anti-Hallucination Reference - -| Correct | Incorrect (do NOT use) | -|---|---| -| `CallModuleEndpointAsync("Module", "Function", args)` | `CallEndpointAsync` for modules | -| `CallEndpointAsync("scriptName", args)` | `CallEndpointAsync` for modules -- scripts only | -| `SubscribeToPlayerMessagesAsync()` -- no parameters | `SubscribeToPlayerMessagesAsync(callbacks)` | -| `SubscribeToProjectMessagesAsync()` -- no parameters | `SubscribeToGeneralMessagesAsync`, `SubscribeToProjectMessagesAsync(callbacks)` | -| `subscription.Callbacks.MessageReceived += handler` | Passing callbacks as constructor/method arg | -| Non-generic `CallEndpointAsync` returns `Task` | Returns `Task` (void) -- it returns a string | -| Non-generic `CallModuleEndpointAsync` returns `Task` | Returns `Task` (void) -- it returns a string | -| `EventConnectionState` enum | `SubscriptionEventConnectionState` | -| `Error` callback is `Action` | `Action` | - ---- - -## Two Execution Models +## Four Execution Models | Model | Language | Client Method | Deployed as | |---|---|---|---| | **Scripts** | JavaScript | `CallEndpointAsync` | `.js` files via Deployment Window | -| **Modules** | C# (.NET 9) | `CallModuleEndpointAsync` | `.ccmr` module references via Deployment Window | - -**Recommendation:** Prefer **C# modules** over JavaScript scripts for production. Modules offer type safety, generated client bindings, and access to NuGet packages. Use scripts only for lightweight prototyping. - ---- - -## ICloudCodeService Methods - -```csharp -// Call a Cloud Code Script endpoint. Returns the script's return value as string. -Task CallEndpointAsync(string function, Dictionary args = default) - -// Call a Cloud Code Script endpoint with typed deserialization. -Task CallEndpointAsync(string function, Dictionary args = default) - -// Call a Cloud Code Module endpoint. Returns the result as string. -// module = module name (filename of .ccmr without extension), function = method name. -Task CallModuleEndpointAsync(string module, string function, - Dictionary args = default, CloudCodeModuleScope scope = default) - -// Call a Cloud Code Module endpoint with typed deserialization. -Task CallModuleEndpointAsync(string module, string function, - Dictionary args = default, CloudCodeModuleScope scope = default) - -// Subscribe to real-time messages sent to the current player. Requires Wire. NO PARAMETERS. -Task SubscribeToPlayerMessagesAsync() - -// Subscribe to real-time project-wide messages. Requires Wire. NO PARAMETERS. -Task SubscribeToProjectMessagesAsync() -``` - -**CRITICAL:** The first argument to `CallModuleEndpointAsync` is the **module name**, the second is the **function name**. Do NOT confuse the parameter order. - ---- - -## Module Runtime Constraints - -- **Cold start:** After ~15 minutes of inactivity, the module's worker shuts down. The next call experiences a latency spike while the worker spins up. Design your UX to tolerate this. -- **No UnityEngine:** Modules run on standard .NET. The `UnityEngine` namespace is NOT available. Use only .NET APIs and NuGet packages. -- **Push messages:** Server-to-client push messages can only be sent from **modules**, not from JavaScript scripts. - ---- - -## Subscriptions - -Cloud Code Subscriptions require `com.unity.services.wire`. Subscribe to real-time server-push messages. - -### `ISubscriptionEvents` - -```csharp -public interface ISubscriptionEvents -{ - SubscriptionEventCallbacks Callbacks { get; } // Wire up handlers here - Task SubscribeAsync(); // Re-subscribe after unsubscribing - Task UnsubscribeAsync(); // Stop receiving messages -} -``` - -### `SubscriptionEventCallbacks` - -Wire up callbacks on the `Callbacks` property of the returned `ISubscriptionEvents`: - -```csharp -var subscription = await CloudCodeService.Instance.SubscribeToPlayerMessagesAsync(); -subscription.Callbacks.MessageReceived += OnMessageReceived; // Action -subscription.Callbacks.MessageBytesReceived += OnBytesReceived; // Action -subscription.Callbacks.ConnectionStateChanged += OnStateChanged; // Action -subscription.Callbacks.Kicked += OnKicked; // Action -subscription.Callbacks.Error += OnError; // Action -``` - -### `IMessageReceivedEvent` Properties - -| Property | Type | Description | -|---|---|---| -| `MessageType` | `string` | Message type identifier set by the server | -| `Message` | `string` | Raw message payload (typically JSON) | -| `Id` | `string` | Unique message ID | -| `CorrelationId` | `string` | Correlation ID for tracing | -| `Source` | `string` | Source of the message | -| `Type` | `string` | CloudEvents type | -| `SpecVersion` | `string` | CloudEvents spec version | -| `Time` | `DateTime` | Timestamp | -| `ProjectId` | `string` | UGS project ID | -| `EnvironmentId` | `string` | UGS environment ID | - -### `EventConnectionState` - -Namespace: `Unity.Services.CloudCode.Subscriptions`. - -| State | Value | Meaning | -|---|---|---| -| `Unknown` | 0 | Initial state | -| `Unsubscribed` | 1 | Not connected | -| `Subscribing` | 2 | Connection in progress | -| `Subscribed` | 3 | Connected and receiving messages | -| `Unsynced` | 4 | Temporarily disconnected, will reconnect | -| `Error` | 5 | Connection error | - ---- - -## Triggers - -Triggers let Cloud Code endpoints run automatically in response to events from other UGS services, without any client call. Configured in the Unity Dashboard or via the UGS CLI, not in client code. - -| Emitter | Example trigger events | -|---|---| -| Authentication | Player signed in, account deleted | -| Scheduler | Cron-based or one-shot scheduled events | -| Leaderboards | Score submitted, leaderboard reset | -| Cloud Save | Data saved, data deleted | - ---- - -## Module Scope - -Scopes require `com.unity.services.cloudcode` 3.x. They are not available in 2.x. - -`CallModuleEndpointAsync` accepts an optional `CloudCodeModuleScope` to scope execution. Namespace: `Unity.Services.CloudCode.Models`. - -```csharp -// Scope to a multiplayer session -var scope = new CloudCodeModuleScope(ScopeType.MultiplayerSession, sessionId); -var result = await CloudCodeService.Instance.CallModuleEndpointAsync( - "MyModule", "MyFunction", args, scope); -``` - -### `ScopeType` Enum - -| Scope Type | Value | Description | -|---|---|---| -| `MultiplayerSession` | 1 | Scoped to a multiplayer session ID | -| `Player` | 2 | Scoped to the calling player (default) | - ---- - -## Module Creation - -A Cloud Code module is a .NET 9 class library built and published by the Deployment Window. - -**A deployable module requires ALL of the following files — skipping any one breaks the build or deploy step:** the `.sln` solution, the `/.csproj` main project (with the NuGet references shown below), the module class (`.cs`) with at least one `[CloudCodeFunction]` endpoint, `ModuleSetup.cs` implementing `ICloudCodeSetup`, `/Properties/PublishProfiles/FolderProfile.pubxml` for the linux-x64 publish, and the `Assets/.../.ccmr` Unity asset pointing at the `.sln`. A `.ccmr` referencing a missing `.sln`, or a module folder lacking `.csproj`/`FolderProfile.pubxml`, is incomplete and the Deployment Window will fail to publish it. - -### Directory Structure - -Place the module at the **project root** (alongside `Assets/`). Unity does not scan the project root, so no `~` suffix is needed. - -> **Tilde rule:** Only add `~` to a folder name (e.g. `MyModule~/`) when the module lives **inside `Assets/`** -- the suffix tells Unity to skip that folder during compilation. At the project root, omit it. - -``` -/ - Assets/ - CloudCode/ - MyModule.ccmr <-- Unity asset pointing to the .sln - MyModuleCCM/ - MyModule.sln - MyModule/ - MyModule.csproj - MyModule.cs <-- [CloudCodeFunction] entry points - ModuleSetup.cs <-- registers IGameApiClient via DI - Properties/ - PublishProfiles/ - FolderProfile.pubxml <-- publish to linux-x64 (required) - MyModule.Tests/ - MyModule.Tests.csproj <-- IsPublishable=false, never deployed - MyModuleTests.cs -``` - -### `.gitignore` (keep module files tracked) - -Unity's stock `.gitignore` excludes `*.sln` and `*.csproj` project-wide. Without an override the module's `.sln`/`.csproj`/`.pubxml` will be silently untracked, which breaks teammates, CI, and any deploy that pulls from git. Add a `.gitignore` **inside the module directory** that re-includes them: - -``` -# CCM/.gitignore -!*.sln -!*.csproj -!Properties/PublishProfiles/*.pubxml -``` - -Verify with `git status` after creating the files -- they must appear as untracked/modified, not be missing from the listing. - -### `.sln` File Template - -Generate fresh GUIDs for `{MAIN-GUID}`, `{TEST-GUID}`, and `{SLN-GUID}` using `[System.Guid]::NewGuid()` in PowerShell or any GUID generator. The test project intentionally has no `Release Build.0` entry to exclude it from Release builds. - -``` -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30114.105 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyModule", "MyModule\MyModule.csproj", "{MAIN-GUID}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyModule.Tests", "MyModule.Tests\MyModule.Tests.csproj", "{TEST-GUID}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {MAIN-GUID}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {MAIN-GUID}.Debug|Any CPU.Build.0 = Debug|Any CPU - {MAIN-GUID}.Release|Any CPU.ActiveCfg = Release|Any CPU - {MAIN-GUID}.Release|Any CPU.Build.0 = Release|Any CPU - {TEST-GUID}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {TEST-GUID}.Debug|Any CPU.Build.0 = Debug|Any CPU - {TEST-GUID}.Release|Any CPU.ActiveCfg = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {SLN-GUID} - EndGlobalSection -EndGlobal -``` - -### `.csproj` (net9.0, CloudCode.Apis 0.0.21, CloudCode.Core 0.0.1) +| **Legacy modules** | C# (.NET 8, up to .NET 10) | generated bindings, or `CallModuleEndpointAsync` | `.ccmr` references to a .NET solution built outside Unity | +| **Cloud Code modules** | C# (.NET 8; .NET 10 on Unity 6.7+) | generated bindings only | `.ccmu` assets, authored natively in the Editor | +| **CloudBehaviours** (experimental) | C# (.NET 8; .NET 10 on Unity 6.7+) | generated bindings — `await behaviour.Method(args)` | `.ccmu` assets, authored natively in the Editor | -```xml - +**Recommendation:** Prefer **C#** over JavaScript scripts for production — type safety, generated client +bindings, and access to NuGet packages. Do not write new scripts; maintain and call existing ones. Prefer authoring +natively in the Editor (`.ccmu`) over a `.ccmr` reference for new work: the module lives in the project, +compiles with it, and needs no separate solution. Between Cloud Code modules and CloudBehaviours, see +the breakdown below. - - net9.0 - enable - Debug;Release - +### Cloud Code Modules vs CloudBehaviours - - - - - +Both are C# modules, and both can hold state per scope with `[StateScope]`. They differ in how the client +reaches them and in whether that state reaches the client on its own: - - true - - - -``` - -### `FolderProfile.pubxml` (linux-x64, SelfContained=false) - -Cloud Code runs on Linux. `SelfContained=false` keeps the output small -- the runtime is provided by the Cloud Code host. - -```xml - - - - Release - Any CPU - bin\Release\net9.0\publish\ - FileSystem - net9.0 - linux-x64 - false - False - - -``` - -### `ModuleSetup.cs` (registers GameApiClient) - -Required once per module. Registers `GameApiClient` so it can be constructor-injected. - -```csharp -using Microsoft.Extensions.DependencyInjection; -using Unity.Services.CloudCode.Apis; -using Unity.Services.CloudCode.Core; - -public class ModuleSetup : ICloudCodeSetup -{ - public void Setup(ICloudCodeConfig config) - { - config.Dependencies.AddSingleton(GameApiClient.Create()); - } -} -``` - -### `MyModule.cs` ([CloudCodeFunction], IExecutionContext, IGameApiClient) - -```csharp -using System; -using System.Globalization; -using Microsoft.Extensions.Logging; -using Unity.Services.CloudCode.Apis; -using Unity.Services.CloudCode.Core; - -public class MyModule -{ - readonly IGameApiClient m_GameApiClient; - readonly ILogger m_Logger; - - public MyModule(IGameApiClient gameApiClient, ILogger logger) - { - m_GameApiClient = gameApiClient; - m_Logger = logger; - } - - // No context needed -- pure computation. - [CloudCodeFunction("SayHello")] - public string SayHello(string name) => $"Hello, {name}!"; - - // Add IExecutionContext as a method parameter only when you need player/project info. - [CloudCodeFunction("GetServerTime")] - public string GetServerTime(IExecutionContext context) - => DateTime.UtcNow.ToString(CultureInfo.InvariantCulture); -} -``` - -**Key concepts:** - -| File / Concept | Purpose | -|---|---| -| `[CloudCodeFunction]` | Marks a public method as a callable endpoint | -| `IExecutionContext` | Optional method parameter -- player ID, project ID, service token | -| `IGameApiClient` | Constructor-injected via DI -- typed clients for UGS APIs | -| `ModuleSetup.cs` | Registers `GameApiClient` via `ICloudCodeSetup` -- required once per module | -| `FolderProfile.pubxml` | Publish profile targeting `linux-x64` -- Cloud Code runs on Linux | -| `IsPublishable=false` | Prevents test project from being included in `dotnet publish` | - -**Dependency injection:** `IGameApiClient` and `ILogger` are constructor-injected. `IExecutionContext` is a method parameter (only add it when the function needs player/project info). - -### Test Project `.csproj` (IsPublishable=false) - -`IsPublishable=false` is enforced in two ways: this property, and the omission of `Release Build.0` in the `.sln`. Both are intentional. - -```xml - - - - net9.0 - latest - enable - enable - false - false - - - - - - - - - - - - - - - - - - - -``` - -### `.ccmr` File Format - -```json -{ - "modulePath": "../../MyModuleCCM/MyModule.sln" -} -``` - -Path is relative to the `.ccmr` file. `../../` goes up from `Assets/CloudCode/` to the project root, then into `MyModuleCCM/`. The filename without `.ccmr` (`MyModule`) is the module name used in `CallModuleEndpointAsync`. - ---- - -## Generated Type-Safe Bindings - -After deploying the module, generate a strongly-typed wrapper instead of using raw `CallModuleEndpointAsync`. The generator reads the deployed module's metadata and emits one C# file per class under `Assets/CloudCode/GeneratedModuleBindings/`. Do not edit these files -- they are overwritten on regeneration. - -### How to Generate - -- Select the `.ccmr` in the **Project window** then click **Generate Bindings** in the Inspector, or -- **Services > Cloud Code > Generate Cloud Code Bindings** to regenerate all at once. - -### Example - -```csharp -// Assets/CloudCode/GeneratedModuleBindings/MyModuleBindings.cs -// This file was generated by Cloud Code Bindings Generator. Modifications will be lost upon regeneration. -public class MyModuleBindings -{ - readonly ICloudCodeService k_Service; - public MyModuleBindings(ICloudCodeService service) { k_Service = service; } - - public async Task SayHello(string name) - => await k_Service.CallModuleEndpointAsync("MyModule", "SayHello", - new Dictionary { { "name", name } }); -} -``` - -```csharp -var bindings = new MyModuleBindings(CloudCodeService.Instance); -var greeting = await bindings.SayHello("World"); -``` - -**Known limitations:** no distinction between required/optional parameters; tuples are not supported. - ---- - -## Code Templates - -### Call a Script Endpoint - -```csharp -using Unity.Services.CloudCode; -using System.Collections.Generic; - -var args = new Dictionary -{ - { "level", 5 }, - { "difficulty", "hard" } -}; - -int reward = await CloudCodeService.Instance.CallEndpointAsync("CalculateReward", args); -Debug.Log($"Reward: {reward}"); -``` - -### Call a Module Endpoint (Typed Result) - -```csharp -// Module class name: "ProgressionModule", method: "AwardXp" -var result = await CloudCodeService.Instance.CallModuleEndpointAsync( - "ProgressionModule", "AwardXp", - new Dictionary { { "amount", 50 } }); - -Debug.Log($"Total XP: {result.TotalXp}"); -``` - -### Subscribe to Player Messages (Full Example) - -```csharp -ISubscriptionEvents subscription; - -async Task SubscribeToMessages() -{ - // Subscribe first -- no parameters - subscription = await CloudCodeService.Instance.SubscribeToPlayerMessagesAsync(); - - // Then wire up callbacks via the Callbacks property - subscription.Callbacks.MessageReceived += OnMessageReceived; - subscription.Callbacks.ConnectionStateChanged += state => - Debug.Log($"Subscription state: {state}"); - subscription.Callbacks.Error += error => - Debug.LogError($"Subscription error: {error}"); - subscription.Callbacks.Kicked += () => - Debug.LogWarning("Kicked from subscription"); -} - -void OnMessageReceived(IMessageReceivedEvent evt) -{ - Debug.Log($"[{evt.MessageType}] {evt.Message}"); - // var data = JsonUtility.FromJson(evt.Message); -} - -async Task Unsubscribe() -{ - await subscription.UnsubscribeAsync(); - subscription = null; -} -``` - ---- - -## Deploying via the Deployment Window - -1. Open **Services > Deployment** in the Unity Editor. -2. All `.js` and `.ccmr` files under `Assets/` are listed -- click **Deploy All**. -3. After deploying a module, generate or regenerate type-safe bindings (see [Generated Type-Safe Bindings](#generated-type-safe-bindings)). - ---- - -## Error Handling - -```csharp -try -{ - var result = await CloudCodeService.Instance.CallModuleEndpointAsync( - "MyModule", "MyFunction", args); -} -catch (CloudCodeRateLimitedException ex) -{ - Debug.LogError($"Rate limited. Retry after {ex.RetryAfter}s"); -} -catch (CloudCodeException ex) -{ - Debug.LogError($"Cloud Code error: {ex.Message} (reason: {ex.Reason})"); -} -``` - -### `CloudCodeExceptionReason` Enum - -| Reason | Value | Meaning | +| | Cloud Code modules | CloudBehaviours | |---|---|---| -| `Unknown` | 0 | Unknown error | -| `NoInternetConnection` | 1 | No network | -| `ProjectIdMissing` | 2 | Project ID not set | -| `PlayerIdMissing` | 3 | Player not signed in | -| `AccessTokenMissing` | 4 | No access token | -| `InvalidArgument` | 5 | Bad input | -| `Unauthorized` | 6 | Not authorized | -| `NotFound` | 7 | Script/module not found | -| `TooManyRequests` | 8 | Rate limited | -| `ServiceUnavailable` | 9 | Service down | -| `ScriptError` | 10 | Script/module execution failure | -| `SubscriptionError` | 11 | Subscription error | +| **Endpoint naming** | one function per `[CloudCodeFunction]` | one generated `[CloudCodeFunction]` per public method you define, named `Behaviour__Method` | +| **Scoped state** | `[StateScope]` persists public fields per player or session | the same, and public fields also synchronize to clients | +| **Client updates** | request/response; subscribe by hand for push messages | synchronized fields and push events arrive on the generated bindings | +| **Client code** | you write the call and deserialize the result | the bindings partial is generated from the cloud class | +| **Requires** | `com.unity.services.cloudcode` `3.0.0`+, Unity 6000.5+ | `com.unity.services.cloudcode` `3.1.0-exp.1`+, Unity 6000.5+ | ---- +**Use CloudBehaviours when clients need to see the state** — shared match state, an inventory the client +displays, per-player views of a shared secret, or a server-owned timer whose deadline the client shows. +The bindings, the synchronization and the push plumbing are generated, so it is less code and less +error-prone than a module plus hand-written calls and subscriptions. -## Asset Store Building Blocks +**Use Cloud Code modules when the client only needs the result of a call** — validating a purchase, awarding a +reward, calling another service, running a trigger on a schedule. Scoped state still works; simpler than +using CloudBehaviours if there is no need for state synchronization. -Several Building Blocks from the Unity Asset Store include Cloud Code modules as working reference implementations: +## Where the details live -- **Achievements Building Block** — Cloud Code module with achievement progress tracking and server-authoritative unlocks. [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-achievements-341918) -- **Player Account Building Block** — Cloud Code module with global score tracking via server-authoritative writes. [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-player-account-341928) -- **Leaderboards Building Block** — Cloud Code modules with number game scoring and leaderboard admin functions. [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-leaderboards-341926) +| Reference | Covers | +| :-- | :-- | +| `references/cloud-code-modules.md` | Cloud Code modules (`.ccmu`), legacy modules (`.ccmr`) and scripts — creating a `.ccmu`, the `.ccmu` asset and how the deployed module name is derived, `ICloudCodeService`, subscriptions, triggers, module scope, generated bindings, deployment, error handling | +| `references/cloud-code-cloudbehaviours.md` | CloudBehaviours — the authoring model, synchronized state, per-viewer projections, timers, access control | -Each block includes `.ccmr` module bindings, generated type-safe client bindings, and the full C# module source. diff --git a/skills/build-live-game/references/cloud-save.md b/skills/build-live-game/references/cloud-save.md index 0a454f7..4692b0a 100644 --- a/skills/build-live-game/references/cloud-save.md +++ b/skills/build-live-game/references/cloud-save.md @@ -448,27 +448,17 @@ catch (CloudSaveException ex) ### `CloudSaveExceptionReason` Enum -| Reason | Value | Meaning | -|---|---|---| -| `Unknown` | 0 | Unknown error | -| `NoInternetConnection` | 1 | No network | -| `ProjectIdMissing` | 2 | Project ID not set | -| `PlayerIdMissing` | 3 | Player not signed in | -| `AccessTokenMissing` | 4 | No access token | -| `InvalidArgument` | 5 | Bad input | -| `Unauthorized` | 6 | Not authorized | -| `KeyLimitExceeded` | 7 | Too many keys stored | -| `NotFound` | 8 | Key not found | -| `TooManyRequests` | 9 | Rate limited | -| `ServiceUnavailable` | 10 | Service down | -| `Conflict` | 11 | Write-lock conflict | - ---- - -## Asset Store Building Blocks - -The following Building Blocks from the Unity Asset Store demonstrate Cloud Save patterns: - -- **Achievements Building Block** — Reads/writes achievement records in Protected buckets via Cloud Code, with Access Control denying direct player writes. [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-achievements-341918) -- **Player Account Building Block** — Stores player profile data in Default and Public access classes with direct client writes for non-sensitive data. [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-player-account-341928) -- **Leaderboards Building Block** — Uses Cloud Save for player score data alongside Cloud Code modules. [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-leaderboards-341926) +| Reason | Value | Meaning | Fix | +|---|---|---| :-- | +| `Unknown` | 0 | Unknown error | Log `ex.Message`, retry once, then report it | +| `NoInternetConnection` | 1 | No network | Retry when connectivity returns | +| `ProjectIdMissing` | 2 | Project ID not set | Link the project in **Project Settings > Services** | +| `PlayerIdMissing` | 3 | Player not signed in | Sign in before calling Cloud Save | +| `AccessTokenMissing` | 4 | No access token | Sign in and check the token has not expired | +| `InvalidArgument` | 5 | Bad input | Correct the keys or values named in the validation details | +| `Unauthorized` | 6 | Not authorized | Check the access class and any Access Control policy denying the call | +| `KeyLimitExceeded` | 7 | Too many keys stored | Delete unused keys | +| `NotFound` | 8 | Key not found | Check the key and the access class used to read it | +| `TooManyRequests` | 9 | Rate limited | Back off and retry after `RetryAfter` | +| `ServiceUnavailable` | 10 | Service down | Retry with backoff | +| `Conflict` | 11 | Write-lock conflict | Reload the keys, merge your change and retry with the new write lock | diff --git a/skills/build-live-game/references/deployment.md b/skills/build-live-game/references/deployment.md index ffb28f7..725ac71 100644 --- a/skills/build-live-game/references/deployment.md +++ b/skills/build-live-game/references/deployment.md @@ -32,13 +32,16 @@ Each service package registers its own file types with the Deployment Window. In | Service | Package | File Type(s) | Min Version | |---|---|---|---| | Cloud Code Scripts | `com.unity.services.cloudcode` | `.js` | 2.1.0 | -| Cloud Code C# Modules | `com.unity.services.cloudcode` | `.ccmr` | 2.5.0 | +| Cloud Code C# Modules (native) | `com.unity.services.cloudcode` | `.ccmu` | 3.0.0 | +| Cloud Code C# Modules (legacy) | `com.unity.services.cloudcode` | `.ccmr` | 2.5.0 | | Remote Config | `com.unity.remote-config` | `.rc` | 3.2.0 | | Leaderboards | `com.unity.services.leaderboards` | `.lb` | 2.0.0 | | Game Server Hosting | `com.unity.services.multiplayer` | `.gsh` | 1.1.0 | | Matchmaker | `com.unity.services.multiplayer` | `.mmq` | 1.0 | | Game Overrides | `com.unity.services.tooling` | `.ugo` | 1.3.0 | | Access Control | `com.unity.services.tooling` | `.ac` | 1.0 | +| Triggers | `com.unity.services.tooling` | `.tr` | 1.4.2 | +| Scheduler | `com.unity.services.tooling` | `.sched` | 1.4.2 | | Deployment Definitions | `com.unity.services.deployment` | `.ddef` | -- | ## Workflow @@ -193,23 +196,36 @@ static void RegisterProvider() ### Code Template -- Trigger Deployment Programmatically +Run it with `unity command run_script --file AgentScripts/Deploy.cs --entry Deploy.Run --args '[["Assets/MyConfig.rc"]]'` +(needs the `com.unity.pipeline` and `com.unity.services.deployment.api` packages; keep the file outside `Assets/`). `DeploymentWindow` throws +*"Deployment Window must be opened for this operation."* until the window is open, so the script opens it +and waits. + ```csharp +// AgentScripts/Deploy.cs — deploys to the active environment. Await; never .Wait(), which deadlocks the main thread. +using System.Linq; +using System.Threading.Tasks; using Unity.Services.DeploymentApi.Editor; +using UnityEditor; -// Deploy all items -var allItems = Deployments.Instance.DeploymentWindow.GetAllDeploymentItems(); -var result = await Deployments.Instance.DeploymentWindow.Deploy(allItems); - -// Deploy by file paths -await Deployments.Instance.DeploymentWindow.Deploy(new[] { "Assets/MyConfig.rc" }); - -// Listen for deployment events -Deployments.Instance.DeploymentWindow.DeploymentStarting += items => -{ - Debug.Log($"Deploying {items.Count} items..."); -}; -Deployments.Instance.DeploymentWindow.DeploymentEnded += items => +public static class Deploy { - Debug.Log($"Deployment complete: {items.Count} items"); -}; + public static async Task Run(string[] paths) + { + var deployments = Deployments.Instance; + if (!(deployments.DeploymentWindow is EditorWindow)) + deployments.DeploymentWindow.OpenWindow(); + for (var i = 0; i < 100 && !(deployments.DeploymentWindow is EditorWindow); i++) + await Task.Delay(100); + + var items = deployments.DeploymentWindow.GetFromFiles(paths); + var found = items.Where(item => item != null).ToList(); + // Deploy all items instead: await deployments.DeploymentWindow.Deploy(deployments.DeploymentWindow.GetAllDeploymentItems()); + if (found.Count > 0) + await deployments.DeploymentWindow.Deploy(found); + return string.Join("\n", paths.Select((path, i) => items[i] == null + ? $"{path}: not a deployable item" + : $"{path}: {items[i].Status.MessageSeverity} {items[i].Status.Message} {items[i].Status.MessageDetail}")); + } +} ``` diff --git a/skills/build-live-game/references/player-account.md b/skills/build-live-game/references/player-account.md index 2d12caf..f6eeabe 100644 --- a/skills/build-live-game/references/player-account.md +++ b/skills/build-live-game/references/player-account.md @@ -31,7 +31,6 @@ - [PlayerAccountsException](#playeraccountsexception) - [CloudSaveException](#cloudsaveexception) - [Validation](#validation) -- [Asset Store Building Block](#asset-store-building-block) --- @@ -124,9 +123,13 @@ Ensure the following packages are listed in `Packages/manifest.json`: **Optional** — add `com.unity.services.cloudcode` if using Protected player data or Custom (shared) data: ```json - "com.unity.services.cloudcode": "2.10.3" + "com.unity.services.cloudcode": "3.0.0" ``` +> `com.unity.services.cloudcode` **`3.0.0`** is the native-authoring line, which the `.ccmu` module +> below requires — see the Requirements section of `references/cloud-code-modules.md`. The `2.10.x` line +> deploys legacy `.ccmr` modules only; use it when maintaining one, not for new work. + --- ## Sign-In: Anonymous @@ -437,13 +440,24 @@ if (result.TryGetValue("player_level", out var item)) Writes to Protected and Custom buckets must go through a Cloud Code module. The module uses `IGameApiClient` with the service token to call Cloud Save server-side APIs. -To create the module, follow the Module Creation section of `references/cloud-code.md`. All scaffolding files are required: +Create it with the Editor rather than by hand: right-click a folder under `Assets/` → +**Create > Services > Cloud Code Module Script** and name it `PlayerAccountModule`. See +the Creating a Cloud Code Module section of `references/cloud-code-modules.md`. +The Editor generates the following — use the list to check what you got, not as files to type out. There is +no `.sln`, `.csproj`, `.pubxml` or test project. + +- [ ] `Assets/CloudCode/PlayerAccountModule/PlayerAccountModule.ccmu` (holds the two asmdef GUIDs) +- [ ] `Cloud/PlayerAccountModule.asmdef` — Editor-only, `noEngineReferences`, `autoReferenced: false` +- [ ] `Cloud/PlayerAccountModule.cs` — rename/replace with the `[CloudCodeFunction]` endpoints +- [ ] `Client/PlayerAccountModuleClient.asmdef` — references the cloud assembly +- [ ] `Client/PlayerAccountModuleClient.cs` — `[CloudModuleClient(typeof(PlayerAccountModule))]` partial + +One file the Editor does **not** generate, which this module needs because its endpoints inject +`IGameApiClient`: -- [ ] `.sln` (generate fresh GUIDs) -- [ ] `.csproj` (net9.0, CloudCode.Apis + CloudCode.Core) -- [ ] `ModuleSetup.cs` (registers `GameApiClient`) -- [ ] `Properties/PublishProfiles/FolderProfile.pubxml` — **without this file, deployment fails with "Failed to retrieve main project"** -- [ ] `Assets/CloudCode/.ccmr` (points to the `.sln`) +- [ ] `Cloud/CloudSetup.cs` — an `ICloudCodeSetup` calling `config.AddGameApiClient()` + +The deployed module name is the **cloud asmdef's name** (`PlayerAccountModule`), not the `.ccmu` filename. --- @@ -741,11 +755,11 @@ catch (RequestFailedException ex) Key error codes: -| Constant | Value | Meaning | -|---|---|---| -| `AccountAlreadyLinked` | 10003 | Username already exists (on sign-up) or external ID already linked | -| `InvalidParameters` | 10002 | Bad input parameters | -| `ClientInvalidUserState` | 10000 | Operation not valid in current state | +| Constant | Value | Meaning | Fix | +|---|---|---| :-- | +| `AccountAlreadyLinked` | 10003 | Username already exists (on sign-up) or external ID already linked | Sign in instead on sign-up, or link with `ForceLink` only after the player confirms | +| `InvalidParameters` | 10002 | Bad input parameters | Validate the input before the call | +| `ClientInvalidUserState` | 10000 | Operation not valid in current state | Check the player's sign-in state before the call | ### PlayerAccountsException @@ -764,10 +778,10 @@ catch (PlayerAccountsException ex) Key error codes (from `PlayerAccountsErrorCodes`): -| Constant | Value | Meaning | -|---|---|---| -| `InvalidState` | 10101 | Player is already signed in | -| `MissingClientId` | 10102 | Client ID not configured in Unity Dashboard | +| Constant | Value | Meaning | Fix | +|---|---|---| :-- | +| `InvalidState` | 10101 | Player is already signed in | Check `PlayerAccountService.Instance.IsSignedIn` before `StartSignInAsync` | +| `MissingClientId` | 10102 | Client ID not configured in Unity Dashboard | Configure the Unity identity provider's client ID in the Unity Dashboard | ### CloudSaveException @@ -805,9 +819,3 @@ After implementing a player account feature, verify: 4. **SignedIn event wiring:** `PlayerAccountService.Instance.SignedIn` is subscribed **before** calling `StartSignInAsync()`. The `StartSignInAsync` Task completes when the browser opens, not when sign-in finishes. 5. **Access class matching:** Data saved with `PublicWriteAccessClassOptions` is loaded with `PublicReadAccessClassOptions`. Data saved with no options (Default) is loaded with `DefaultReadAccessClassOptions` or no options. Mismatched access classes return empty results without error. 6. **SignOut is synchronous:** `AuthenticationService.Instance.SignOut()` and `PlayerAccountService.Instance.SignOut()` are both `void` methods. Do not `await` them. - ---- - -## Asset Store Building Block - -- **Player Account Building Block:** Complete reference implementation with sign-in UI (anonymous, Unity browser, username/password), Cloud Save data management, and a Cloud Code module with generated client bindings. [Asset Store](https://assetstore.unity.com/packages/essentials/tutorial-projects/unity-building-block-player-account-341928) diff --git a/skills/build-live-game/references/ugs-cli.md b/skills/build-live-game/references/ugs-cli.md new file mode 100644 index 0000000..d887217 --- /dev/null +++ b/skills/build-live-game/references/ugs-cli.md @@ -0,0 +1,86 @@ +# UGS CLI Reference + +## Table of Contents + +- [Overview](#overview) +- [Install and Update](#install-and-update) +- [If the Unity CLI Itself Is Missing](#if-the-unity-cli-itself-is-missing) +- [Auth and Project Setup](#auth-and-project-setup) +- [Running Commands](#running-commands) +- [Headless Deploy by File Type](#headless-deploy-by-file-type) +- [Credentials and Environment](#credentials-and-environment) + +## Overview + +The [Unity Gaming Services CLI](https://github.com/Unity-Technologies/unity-gaming-services-cli/) manages UGS resources outside the Unity Editor: deploy and fetch cloud resource files, manage triggers and schedules, and reach admin functionality across all UGS services. Every command in this reference is invoked as `unity ugs ` — there is no standalone `ugs` form. + +## Install and Update + +**It ships as a Unity CLI plugin**, so install and update it through the Unity CLI rather than downloading it separately. Before running any `unity ugs` command, confirm the plugin is installed and install it if it's missing: + +```bash +unity plugin list # every component, its state, and where it resolved from — check ugs is "resolved" +unity plugin install ugs # install the managed copy, if missing +unity plugin upgrade ugs # re-install when the pinned build changes +``` + +If `unity plugin list` shows `ugs` resolved from `path` rather than `managed`, a UGS CLI already on PATH is used instead of the plugin, and may be an older version without the features above (e.g. Hub sign-in needs 2.0.0+). Check `unity ugs --version`; remove the other install or upgrade it. + +For the plugin system itself (what a managed tool is, `unity plugin remove` / `changelog`), see the **unity-cli** skill. + +## If the Unity CLI Itself Is Missing + +If `unity` is not on PATH at all, `unity plugin install ugs` can't run. Don't stop at a raw "command not found" — tell the user to install the Unity CLI first, following the **unity-cli** skill's own install instructions (its own `--help`/docs are the source of truth for the current install command). + +## Auth and Project Setup + +Before any `unity ugs` command that talks to the backend, the CLI needs credentials and a target project/environment: + +```bash +unity ugs config set project-id +unity ugs config set environment-name +``` + +**Developer sign-in — Unity Hub (requires UGS CLI 2.0.0+):** with Unity Hub running and signed in, either run `unity ugs login` with its Hub option (see `unity ugs login --help`) to persist Hub auth, or set `UGS_CLI_USE_HUB_AUTH=true` (also accepts `1`/`yes`/`y`/`t`) to use it for one invocation without logging in — it takes priority over any saved `unity ugs login` and the service-account env vars below for that call. Hub sign-in reads the currently-running Hub's session over its local IPC socket (`/tmp/Unity-hubIPCService.sock` on macOS/Linux, a named pipe on Windows), not a file or keychain, so Hub has to actually be running and signed in on the machine the CLI runs on. The CLI prints bare `ugs` in its messages; run the equivalent as `unity ugs logout` then `unity ugs login`. Hub sign-in failures: + +| Error string | Cause | Fix | +| :-- | :-- | :-- | +| ``Unity Hub is not running. Start Hub or run `ugs logout` then `ugs login` to switch to service account authentication.`` | Hub is not running on this machine | Start Hub and sign in, or switch to a service account | +| ``Unity Hub user is not signed in. Sign in through Unity Hub or run `ugs logout` then `ugs login` to switch to service account authentication.`` | Hub is running but nobody is signed in | Sign in through Hub, or switch to a service account | + +Run `unity ugs --version` first — v1.9.0 predates Hub auth; `unity plugin upgrade ugs` if it reports lower than 2.0.0. Note `unity ugs status` does **not** reflect `UGS_CLI_USE_HUB_AUTH` — it only detects a persisted Hub login, so it can still report "no credentials" while a command using the env var succeeds. + +**Service account — CI / headless:** + +```bash +unity ugs login --service-key-id --secret-key-stdin # prompts for the secret key on stdin +# or set UGS_CLI_SERVICE_KEY_ID / UGS_CLI_SERVICE_SECRET_KEY instead of logging in +``` + +`unity ugs status` reports whether a service account is currently stored. Run `unity ugs login --help` / `unity ugs config --help` to confirm flags against the installed CLI version before relying on this from memory. + +## Running Commands + +Invoke it as `unity ugs `: + +```bash +unity ugs login # or UGS_CLI_SERVICE_KEY_ID / UGS_CLI_SERVICE_SECRET_KEY +unity ugs deploy . --dry-run +unity ugs fetch . -s remote-config -e production +``` + +## Headless Deploy by File Type + +`unity ugs deploy` picks files up by extension and silently skips the rest. A skipped file does not show up in the result at all, so a dry run that lists nothing means nothing was recognised. + +| Resource | Headless path | +|---|---| +| `.ccmu` (native C# module) | **Not the UGS CLI** — `unity ugs deploy` skips `.ccmu`. The Editor compiles the module and deploys it: the Deployment Window, or headlessly the `Deploy.cs` template in `references/deployment.md`. | +| `.ccmr` (legacy module) | `unity ugs deploy` skips the `.ccmr` itself — pass the `.sln` it points at: `unity ugs deploy Module~/Module.sln`. The CLI runs `dotnet publish` (needs a .NET SDK on PATH). The Deployment Window deploys the `.ccmr` directly. | +| `.ccm` (packaged module) | `unity ugs deploy .ccm` | +| `.rc`, `.ac`, `.lb`, `.js`, `.tr`, `.sched` | `unity ugs deploy ` and `unity ugs fetch `, or the Deployment Window. `--dry-run` first; `--reconcile` deletes remote content not in the deploy. `unity ugs tr new-file ` / `unity ugs sched new-file ` generate a default trigger / schedule config. | +| `.ugo` (Game Overrides) | **Not the UGS CLI** — `unity ugs deploy` skips `.ugo`. Deploy through the Deployment Window, or headlessly the `Deploy.cs` template in `references/deployment.md`. | + +## Credentials and Environment + +`unity ugs deploy`/`fetch` authenticate using whichever CLI credential is active — a service account (`unity ugs login` or its env vars) or, from 2.0.0+, Unity Hub sign-in (see [Auth and Project Setup](#auth-and-project-setup)) — never as the signed-in Editor user, and take the environment as an argument (`-e `). An Editor deploy uses the Editor's own credentials and the project's *active* environment — except that while the local Cloud Code server runs, a Cloud Code module deploy goes to that server instead. Stop the local server before deploying a module remotely.