# PlayOps SDK — Complete Documentation > The PlayOps SDK is a Unity package by Tactile Games that connects mobile games to the PlayOps backend. It provides two services: **Analytics** (structured event logging with schema validation) and **Remote Configuration** (live JSON config delivery without app updates). Target platform: Unity 6000.0 LTS+, iOS and Android only. --- ## Getting Started — Introduction The PlayOps SDK is a Unity package developed by Tactile Games that connects your game to the PlayOps backend services. It is designed for partner studios who want to instrument their games with analytics and deliver live configuration changes — without building or maintaining their own backend infrastructure. ## What's included The SDK ships two services out of the box: **Analytics** — Log player behaviour events from your Unity game code. Events are defined as plain C# classes decorated with attributes. The SDK batches and flushes them to Tactile's analytics pipeline. A build processor automatically generates an event schema file that you upload to the PlayOps dashboard so events can be validated and queried. **Remote Configuration** — Download and apply JSON configuration from the PlayOps backend at runtime. Configuration is deserialized into strongly-typed C# classes, so you interact with plain objects rather than raw dictionaries. Changes made in the dashboard are delivered to players on their next session without a new app release. ## Who this is for This documentation is written for Unity C# engineers at partner studios integrating PlayOps into a mobile game. You will need: - Access to your PlayOps credentials (provided by your Tactile integration contact) - Unity 6000.0 LTS or newer - Familiarity with Unity's Package Manager and C# scripting ## Using AI to integrate If you use an AI coding assistant (Claude, ChatGPT, Cursor, Copilot, etc.), you can give it [llms.txt](/llms.txt) — a lightweight index of the SDK documentation with links to each section. The AI can use it as a map to find exactly what it needs. If your tool doesn't support fetching URLs, use [llms-full.txt](/llms-full.txt) instead — the complete SDK documentation compiled into a single file you can paste or attach directly. ## Next steps Follow the [Installation](/getting-started/installation/) guide to set up registry access, then continue to the [Quick Start](/getting-started/quick-start/) to initialize the SDK. --- ## Installation export const manifestSnippet = `{ "dependencies": { "com.tactilegames.playops-sdk": "${sdkVersion.version}" } }`; ## Prerequisites - **Unity 6000.0 LTS** or newer - **Build target set to iOS or Android** — the SDK does not support Standalone, WebGL, or other platforms - **Registry token** from your Tactile integration contact (for package access) - **Service credentials** from your Tactile integration contact: | Value | Description | |-------|-------------| | `cloudUrl` | Base URL of your game's PlayOps backend (e.g. `https://my-game.tactilews.com`) | | `gameSecret` | UUID that uniquely identifies your game | | `analyticsAppId` | Your analytics namespace (e.g. `my-game-dev`) | ## 1. Configure registry authentication The PlayOps SDK and its dependencies are hosted on a private package registry. You need to configure authentication on your machine before Unity can download them. 1. Locate your Unity configuration file: ``` ~/.upmconfig.toml ``` ``` %USERPROFILE%\.upmconfig.toml ``` 2. Open (or create) the `.upmconfig.toml` file and add the following: ```toml [npmAuth."https://npm.pkg.github.com/@tactilegames"] token = "YOUR_TOKEN_HERE" ``` 3. Replace `YOUR_TOKEN_HERE` with the registry token provided by Tactile. Each developer on your team needs this file on their machine. The `.upmconfig.toml` file lives in your **home directory**, not inside your Unity project — it should never be committed to version control. ## 2. Add the scoped registry Open `Packages/manifest.json` in your Unity project and add the Tactile Games scoped registry: ```json { "scopedRegistries": [ { "name": "Tactile Games", "url": "https://npm.pkg.github.com/@tactilegames", "scopes": [ "com.tactilegames" ] } ], "dependencies": { ... } } ``` This tells Unity to resolve any package starting with `com.tactilegames` from the Tactile registry. All other packages continue to resolve from Unity's default registry. ## 3. Install the SDK Add the SDK to your `dependencies` in `Packages/manifest.json`: The latest SDK version is **{sdkVersion.version}** — see the [Releases — Changelog](/changelog/) for the full history. Unity will automatically resolve all transitive dependencies — no need to list them individually. After saving `manifest.json`, switch to the Unity Editor and wait for package resolution to complete. You will see a progress bar in the bottom-right corner. Your project will not compile until all packages are downloaded. Alternatively, install via the Unity Editor: 1. Open **Window > Package Manager**. 2. Click the **+** button and choose **Add package by name**. 3. Enter `com.tactilegames.playops-sdk` and version {sdkVersion.version}. 4. Click **Add**. ## 4. Verify the installation After Unity finishes importing: 1. Open **Window > Package Manager**. 2. Switch the filter to **In Project**. 3. You should see **PlayOps SDK** and its dependencies listed. If you see authentication errors, double-check that: - `.upmconfig.toml` exists in your home directory (not your project) - The token is correct and has not expired - The `scopedRegistries` entry is present in `manifest.json` ## Next steps Continue to the [Quick Start](/getting-started/quick-start/) to initialize the SDK and make your first API call. --- ## Quick Start This guide assumes you have completed the [Installation](/getting-started/installation/) steps and the SDK is imported in your project. ## Scene setup 1. In your Unity scene, create an empty GameObject (e.g. `PlayOpsBootstrap`). 2. Attach a new MonoBehaviour script to it (e.g. `GameBootstrap.cs`). 3. Add the initialization code in `Awake()` so it runs before any other game logic. ## Initialize the SDK ```csharp using PlayOps; using PlayOps.Analytics; using PlayOps.Configurations; using UnityEngine; public class GameBootstrap : MonoBehaviour { private PlayOpsSDK playOpsSDK; void Awake() { playOpsSDK = new PlayOpsSDK( new PlayOpsSettings( cloudUrl: "GWS_CLOUD_URL", gameSecret: "GWS_SECRET_KEY" ), new AnalyticsSettings( appId: "ANALYTICS_APP_ID" ) ); playOpsSDK.Initialize(); } } ``` Replace the placeholder values with the credentials provided by your Tactile integration contact. Never hardcode credentials in source control. Use per-environment configuration files or inject them via your build pipeline. ## Access services Once initialized, access the SDK services through the `PlayOpsSDK` instance: ```csharp PlayOpsAnalytics analytics = playOpsSDK.Analytics(); PlayOpsConfigurations configurations = playOpsSDK.Configurations(); configurations.Initialize(); ``` You must call `configurations.Initialize()` before reading any configuration values. This loads the bundled fallback configuration into memory. ## What's next - [Defining Custom Events](/analytics/defining-custom-events/) — define your first analytics event - [Remote Configuration](/remote-configuration/overview/) — deliver live config changes without a new app release --- ## Analytics — Analytics Overview The analytics service lets you log structured player behaviour events from your Unity game. Events are defined as plain C# classes, batched locally, and flushed to Tactile's analytics pipeline over HTTPS. ## How it works 1. You define event classes decorated with `[TactileAnalytics.EventAttribute]` and property-level description attributes. 2. During a Unity build, the SDK's build processor scans your assemblies, finds all event classes, and writes a versioned schema file to `[Output]/TactileAnalytics/` in your project root. 3. You upload the schema file to the PlayOps dashboard to register your schema. 4. At runtime, your game calls `sdk.Analytics().LogEvent(new MyEvent(...))`. The SDK batches events and flushes them asynchronously. 5. The pipeline validates incoming events against the registered schema before ingesting them. ## Accessing the service ```csharp PlayOpsAnalytics analytics = sdk.Analytics(); ``` `Analytics()` throws `PlayOpsNotInitializedException` if called before `sdk.Initialize()`. ## Event lifecycle Events are logged synchronously from your game thread — `LogEvent` returns immediately. Events are persisted to disk in batches and sent asynchronously, so events are not lost if the app is force-quit, crashes, or is offline — batches are retried on the next launch or when the app resumes, until the server acknowledges them. --- ## Defining Custom Events Analytics events are plain C# classes decorated with attributes. The SDK's build processor scans your assemblies, reads the attributes, and generates the event schema automatically. ## Minimal event ```csharp using TactileModules.TactileAnalyticsModule; [TactileAnalytics.EventAttribute("levelCompleted")] public class LevelCompleteEvent { [EventPropertyDescription("The level identifier.")] private TactileAnalytics.RequiredParam levelName { get; set; } public LevelCompleteEvent(string levelName) { this.levelName = levelName; } } ``` ## Full example with multiple properties ```csharp using TactileModules.TactileAnalyticsModule; [TactileAnalytics.EventAttribute("levelCompleted")] [EventCategory("Progression")] [EventDescription("Fired when the player successfully finishes a level.")] public class LevelCompleteEvent { [EventPropertyDescription("The level identifier, e.g. 'world1_level3'.")] private TactileAnalytics.RequiredParam levelName { get; set; } [EventPropertyDescription("Stars earned on completion (0–3).")] private TactileAnalytics.RequiredParam starsEarned { get; set; } [EventPropertyDescription("Elapsed time in seconds.")] private TactileAnalytics.OptionalParam elapsedSeconds { get; set; } [EventPropertyDescription("Whether any boosters were active during this attempt.")] private TactileAnalytics.RequiredParam boostersActive { get; set; } public LevelCompleteEvent(string levelName, int starsEarned, bool boostersActive, double elapsedSeconds = 0) { this.levelName = levelName; this.starsEarned = starsEarned; this.boostersActive = boostersActive; this.elapsedSeconds = elapsedSeconds; } } ``` ## Attribute reference ### `[TactileAnalytics.EventAttribute(string name)]` Required on the class. `name` is the event name that appears in the data warehouse. Use `snake_case`. ### `[EventCategory(string category)]` Optional. Groups events in the dashboard UI. Examples: `"Progression"`, `"Monetisation"`, `"Debug"`. ### `[EventDescription(string description)]` Optional but strongly recommended. Human-readable description of when this event fires. Shown in the schema viewer and used for generated documentation. ### `[EventPropertyDescription(string description)]` Required on each property you want included in the schema. Without this attribute, the property is ignored by the schema generator. ### `[EventChangeReason(string reason, string developer, string stakeholder)]` Required when modifying an existing event schema. Tracks why the schema changed, who made the change, and who requested it. All three parameters are required. ```csharp [EventChangeReason("Add elapsed time property", "dev@studio.com", "pm@studio.com")] ``` ### `[PersonalData]` Marks a property as containing personal or sensitive data. Used for data governance and compliance. ```csharp [PersonalData] [EventPropertyDescription("Player email if provided.")] private TactileAnalytics.OptionalParam email { get; set; } ``` ## Required and optional properties Properties are wrapped in generic structs to indicate whether they are required or optional: ### `RequiredParam` The schema generator marks these properties as required. The value must always be provided. ```csharp [EventPropertyDescription("The level identifier.")] private TactileAnalytics.RequiredParam levelName { get; set; } ``` ### `OptionalParam` The schema generator marks these properties as optional. Use for values that may not always be available. ```csharp [EventPropertyDescription("Local client price from the store.")] private TactileAnalytics.OptionalParam localPrice { get; set; } ``` Both types have implicit conversion operators, so you can assign values directly: ```csharp this.levelName = "world1_level3"; this.localPrice = 4.99; ``` ### Supported types for `T` | C# Type | Description | |---------|-------------| | `string` | Text values | | `int` | Integer values | | `double` | Decimal values (float is not supported) | | `bool` | True/false values | | `DateTime` | Timestamp values | Keep event names stable. Renaming an event or removing a required property is a breaking schema change — you will need to upload a new schema and the old event name will stop receiving data. ## Visibility Event classes must be declared `public` so the build processor can discover and scan them during schema generation. --- ## Logging Events ## LogEvent Call `LogEvent` with an instance of any class decorated with `[TactileAnalytics.EventAttribute]`: ```csharp var analytics = playOpsSDK.Analytics(); analytics.LogEvent(new LevelCompleteEvent("level_01_easy")); ``` `LogEvent` accepts any `object` — the SDK reflects on it at flush time to extract property values. The event class must be decorated with the correct attributes for the schema to be valid. See [Defining Custom Events](/analytics/defining-custom-events/). ## LogPurchase Log an in-app purchase transaction. The receipt is sent to the server for validation: ```csharp using TactileModules.Analytics; var product = new Product( id: "com.mygame.coins_500", title: "500 Coins", price: 499, // price in cents localCurrencyCode: "USD", localPrice: 4.99 ); var transaction = new Transaction( purchaseSessionId: sessionId, product: product, transactionId: transactionId, transactionReceipt: receiptString, orderId: orderId, transactionSignature: signature, transactionTimestamp: DateTime.UtcNow, purchaseProvider: "apple" // or "google" ); analytics.LogPurchase(transaction); ``` Purchase validation happens server-side against the Apple or Google receipt APIs. The result is attributed back to the player's session in the dashboard. ## Subscribing to logged events Use the `EventLogged` event if you need to observe outgoing analytics events — useful for debugging or forwarding to a secondary analytics provider: ```csharp analytics.EventLogged += (evt) => { Debug.Log($"[Analytics] {evt.Name}"); }; ``` --- ## Suggested Events The PlayOps SDK ships with a small set of built-in events (`gameStarted`, `newPlayer`, …) that fire automatically. Beyond those, every game logs its own gameplay-specific events — and most games end up logging the same kinds of things. This section is a catalog of events we recommend implementing. They give you a consistent foundation that dashboards, A/B-test analyses, and cross-game benchmarks can rely on. These events are **not** built into the SDK. You define and log them yourself, following the patterns in [Defining Custom Events](/analytics/defining-custom-events/) and [Logging Events](/analytics/logging-events/). Treat the names, types, and field semantics in this section as the canonical contract — analytics tooling expects events with these exact shapes. You can add extra fields for game-specific context, but don't rename or remove the documented ones. ## Categories ### Progression Events that bracket the player's run through the main level flow. They share `LevelSessionId` as the join key — generate a fresh GUID at `levelStarted` and reuse it on every event for that session. A retry (fresh board) is a new session with a new ID; a continue (same board, extra resources) keeps the same ID. A session opens with `levelStarted`. Within a session, the player may fail and continue any number of times — each failure fires `levelFailed`, and each continue fires `levelContinued` after it. The session is closed by `levelCompleted` (player won), `levelAbandoned` (player exited mid-session), or a final `levelFailed` that the player does not continue from. - [`levelStarted`](/analytics/suggested-events/level-started/) — Opens a new level session. - [`levelCompleted`](/analytics/suggested-events/level-completed/) — Closes a session — player won. - [`levelFailed`](/analytics/suggested-events/level-failed/) — Closes a session — player failed without continuing. - [`levelAbandoned`](/analytics/suggested-events/level-abandoned/) — Closes a session — player exited before a resolution. - [`levelContinued`](/analytics/suggested-events/level-continued/) — Mid-session — player continued after a failure. ### Global state Global state events carry context that the SDK automatically attaches to outgoing analytics events. They make it trivial to join or segment any event by these dimensions without having to repeat the values on every event. - [`LevelSessionIdGlobalState`](/analytics/suggested-events/level-session-id-global-state/) — Current level session ID, for joining any event back to its session. - [`UserProgressionGlobalState`](/analytics/suggested-events/user-progression-global-state/) — User's main-map level progression, for segmenting any event by progression. --- ## levelStarted A [suggested event](/analytics/suggested-events/overview/) — define and log it yourself following the patterns in [Defining Custom Events](/analytics/defining-custom-events/). The shape below is the canonical contract. **Category:** Progression **When it fires** — At the beginning of every level session, both fresh plays and retries. This event marks the start of a new level session; it must be paired with a closing event ([`levelCompleted`](/analytics/suggested-events/level-completed/), [`levelFailed`](/analytics/suggested-events/level-failed/), or [`levelAbandoned`](/analytics/suggested-events/level-abandoned/)) carrying the same `LevelSessionId`. ## Fields ### `LevelNumber` **Type:** `int` · **Required:** Yes · **Example:** `17` Level number within the main progression. ### `LevelSessionId` **Type:** `string` · **Required:** Yes · **Example:** `b1f4888f-aec5-487e-bec5-328c6c95e1b5` Unique identifier for the level session. Generate a fresh GUID at session start and reuse it on every event tied to that session. ## C# example ```csharp using TactileModules.TactileAnalyticsModule; [TactileAnalytics.EventAttribute("levelStarted")] [EventCategory("Progression")] [EventDescription("Fired when a level starts with a fresh board (from play or retry). Marks the beginning of a new LevelSession.")] public class LevelStartedEvent { [EventPropertyDescription("Level number within the main progression.")] private TactileAnalytics.RequiredParam LevelNumber { get; set; } [EventPropertyDescription("Unique identifier of the level session.")] private TactileAnalytics.RequiredParam LevelSessionId { get; set; } public LevelStartedEvent(int levelNumber, string levelSessionId) { this.LevelNumber = levelNumber; this.LevelSessionId = levelSessionId; } } ``` ## Logging it ```csharp var levelSessionId = System.Guid.NewGuid().ToString(); analytics.LogEvent(new LevelStartedEvent(levelNumber: 17, levelSessionId: levelSessionId)); ``` ## See also - [`levelCompleted`](/analytics/suggested-events/level-completed/) — Closes the session — player won. - [`levelFailed`](/analytics/suggested-events/level-failed/) — Fires on each failure during the session. - [`levelAbandoned`](/analytics/suggested-events/level-abandoned/) — Closes the session — player exited. - [`levelContinued`](/analytics/suggested-events/level-continued/) — Fires after each `levelFailed` when the player continues. --- ## levelCompleted A [suggested event](/analytics/suggested-events/overview/) — define and log it yourself following the patterns in [Defining Custom Events](/analytics/defining-custom-events/). The shape below is the canonical contract. **Category:** Progression **When it fires** — At the moment the player successfully clears the current level session. Closes the session opened by [`levelStarted`](/analytics/suggested-events/level-started/) and carries the same `LevelSessionId`. ## Fields ### `LevelNumber` **Type:** `int` · **Required:** Yes · **Example:** `22` Level number within the main progression. ### `LevelSessionId` **Type:** `string` · **Required:** Yes · **Example:** `9d7e8a41-5af0-42f3-996e-4d8aa03fb855` The session identifier from the matching `levelStarted`. ### `LevelSecondsPlayed` **Type:** `int` · **Required:** Yes · **Example:** `156` Seconds spent actively playing this level session. Powers downstream session-duration metrics. ## C# example ```csharp using TactileModules.TactileAnalyticsModule; [TactileAnalytics.EventAttribute("levelCompleted")] [EventCategory("Progression")] [EventDescription("Fired when the level is successfully completed.")] public class LevelCompletedEvent { [EventPropertyDescription("Level number within the main progression.")] private TactileAnalytics.RequiredParam LevelNumber { get; set; } [EventPropertyDescription("Unique identifier of the level session.")] private TactileAnalytics.RequiredParam LevelSessionId { get; set; } [EventPropertyDescription("Seconds spent actively playing this level session.")] private TactileAnalytics.RequiredParam LevelSecondsPlayed { get; set; } public LevelCompletedEvent(int levelNumber, string levelSessionId, int levelSecondsPlayed) { this.LevelNumber = levelNumber; this.LevelSessionId = levelSessionId; this.LevelSecondsPlayed = levelSecondsPlayed; } } ``` ## See also - [`levelStarted`](/analytics/suggested-events/level-started/) — Opens the session. - [`levelFailed`](/analytics/suggested-events/level-failed/) — Fires on each failure during the session. - [`levelAbandoned`](/analytics/suggested-events/level-abandoned/) — Closes the session — player exited. - [`levelContinued`](/analytics/suggested-events/level-continued/) — Fires after each `levelFailed` when the player continues. --- ## levelFailed A [suggested event](/analytics/suggested-events/overview/) — define and log it yourself following the patterns in [Defining Custom Events](/analytics/defining-custom-events/). The shape below is the canonical contract. **Category:** Progression **When it fires** — When the level attempt fails (e.g. out of moves, time up, life lost). Fires on every failure moment within the session. If the player chooses to continue, [`levelContinued`](/analytics/suggested-events/level-continued/) fires next and the session stays open. Otherwise, this event closes the session. ## Fields ### `LevelNumber` **Type:** `int` · **Required:** Yes · **Example:** `1` Level number within the main progression. ### `LevelSessionId` **Type:** `string` · **Required:** Yes · **Example:** `df4228b1-23f9-489d-8e1a-d8c723fc2731` The session identifier from the matching `levelStarted`. ### `LevelSecondsPlayed` **Type:** `int` · **Required:** Yes · **Example:** `261` Seconds spent actively playing this level session. Powers downstream session-duration metrics. ## C# example ```csharp using TactileModules.TactileAnalyticsModule; [TactileAnalytics.EventAttribute("levelFailed")] [EventCategory("Progression")] [EventDescription("Fired when the level attempt fails. May be followed by levelContinued, or close the session.")] public class LevelFailedEvent { [EventPropertyDescription("Level number within the main progression.")] private TactileAnalytics.RequiredParam LevelNumber { get; set; } [EventPropertyDescription("Unique identifier of the level session.")] private TactileAnalytics.RequiredParam LevelSessionId { get; set; } [EventPropertyDescription("Seconds spent actively playing this level session.")] private TactileAnalytics.RequiredParam LevelSecondsPlayed { get; set; } public LevelFailedEvent(int levelNumber, string levelSessionId, int levelSecondsPlayed) { this.LevelNumber = levelNumber; this.LevelSessionId = levelSessionId; this.LevelSecondsPlayed = levelSecondsPlayed; } } ``` ## See also - [`levelStarted`](/analytics/suggested-events/level-started/) — Opens the session. - [`levelCompleted`](/analytics/suggested-events/level-completed/) — Closes the session — player won. - [`levelAbandoned`](/analytics/suggested-events/level-abandoned/) — Closes the session — player exited. - [`levelContinued`](/analytics/suggested-events/level-continued/) — Fires after this event when the player continues; the session then stays open. --- ## levelAbandoned A [suggested event](/analytics/suggested-events/overview/) — define and log it yourself following the patterns in [Defining Custom Events](/analytics/defining-custom-events/). The shape below is the canonical contract. **Category:** Progression **When it fires** — When the player exits a level session before it reaches a terminal state — for example, by tapping back to the map or backgrounding/closing the app mid-session — without completing or failing. ## Fields ### `LevelNumber` **Type:** `int` · **Required:** Yes · **Example:** `176` Level number within the main progression. ### `LevelSessionId` **Type:** `string` · **Required:** Yes · **Example:** `079db175-5aca-4299-a996-692c8a2bcd0c` The session identifier from the matching `levelStarted`. ### `LevelSecondsPlayed` **Type:** `int` · **Required:** Yes · **Example:** `211` Seconds spent actively playing this level session. For abandons, this is the primary signal of how long the player engaged before disengaging. ## C# example ```csharp using TactileModules.TactileAnalyticsModule; [TactileAnalytics.EventAttribute("levelAbandoned")] [EventCategory("Progression")] [EventDescription("Fired when the player abandons a level session before reaching a final state (neither completed nor failed).")] public class LevelAbandonedEvent { [EventPropertyDescription("Level number within the main progression.")] private TactileAnalytics.RequiredParam LevelNumber { get; set; } [EventPropertyDescription("Unique identifier of the level session.")] private TactileAnalytics.RequiredParam LevelSessionId { get; set; } [EventPropertyDescription("Seconds spent actively playing this level session.")] private TactileAnalytics.RequiredParam LevelSecondsPlayed { get; set; } public LevelAbandonedEvent(int levelNumber, string levelSessionId, int levelSecondsPlayed) { this.LevelNumber = levelNumber; this.LevelSessionId = levelSessionId; this.LevelSecondsPlayed = levelSecondsPlayed; } } ``` ## See also - [`levelStarted`](/analytics/suggested-events/level-started/) — Opens the session. - [`levelCompleted`](/analytics/suggested-events/level-completed/) — Closes the session — player won. - [`levelFailed`](/analytics/suggested-events/level-failed/) — Fires on each failure during the session. - [`levelContinued`](/analytics/suggested-events/level-continued/) — Fires after each `levelFailed` when the player continues. --- ## levelContinued A [suggested event](/analytics/suggested-events/overview/) — define and log it yourself following the patterns in [Defining Custom Events](/analytics/defining-custom-events/). The shape below is the canonical contract. **Category:** Progression **When it fires** — After a [`levelFailed`](/analytics/suggested-events/level-failed/) within a session, when the player elects to continue (continue, rewarded ad, extra moves). The same session continues with the same `LevelSessionId`; a new session is *not* opened. This is a mid-session event — it does not close the session. ## Fields ### `LevelSessionId` **Type:** `string` · **Required:** Yes · **Example:** `757fb910-1143-4449-87df-cab18fbe94bc` The session identifier from the matching `levelStarted`. ## C# example ```csharp using TactileModules.TactileAnalyticsModule; [TactileAnalytics.EventAttribute("levelContinued")] [EventCategory("Progression")] [EventDescription("Fired when the player continues the level after a failure (continue, rewarded ad, extra moves).")] public class LevelContinuedEvent { [EventPropertyDescription("Unique identifier of the level session.")] private TactileAnalytics.RequiredParam LevelSessionId { get; set; } public LevelContinuedEvent(string levelSessionId) { this.LevelSessionId = levelSessionId; } } ``` ## See also - [`levelStarted`](/analytics/suggested-events/level-started/) — Opens the session. - [`levelFailed`](/analytics/suggested-events/level-failed/) — Precedes this event; closes the session if no continue follows. - [`levelCompleted`](/analytics/suggested-events/level-completed/) — Closes the session — player won. - [`levelAbandoned`](/analytics/suggested-events/level-abandoned/) — Closes the session — player exited. --- ## Global state — LevelSessionIdGlobalState A [suggested event](/analytics/suggested-events/overview/) — define and log it yourself following the patterns in [Defining Custom Events](/analytics/defining-custom-events/). The shape below is the canonical contract. **Category:** Global state **Purpose** — A global state event carrying the current `LevelSessionId`. Automatically attached to outgoing analytics events so any event can be trivially joined back to the containing level session. ## Fields ### `LevelSessionId` **Type:** `string` · **Required:** Yes · **Example:** `b1f4888f-aec5-487e-bec5-328c6c95e1b5` The ID of the current level session. A new ID is generated prior to playing any level — when the player presses Play on the main map, or replays a level after a failure. If the player runs out of moves but uses a continue option (paying with coins, IAP, or watching a rewarded ad), the level session ID remains the same — a new ID is only generated when a level is (re)started. ## C# example ```csharp using TactileModules.TactileAnalyticsModule; [TactileAnalytics.EventAttribute("LevelSessionIdGlobalState")] [EventCategory("Global state")] [EventDescription("Global state event carrying the current LevelSessionId. Attached to analytics events to make it trivial to join any event to the containing level session.")] public class LevelSessionIdGlobalStateEvent { [EventPropertyDescription("The ID of the current level session.")] private TactileAnalytics.RequiredParam LevelSessionId { get; set; } public LevelSessionIdGlobalStateEvent(string levelSessionId) { this.LevelSessionId = levelSessionId; } } ``` ## See also - [`levelStarted`](/analytics/suggested-events/level-started/) — Each new level session begins here, generating a fresh `LevelSessionId`. - [`UserProgressionGlobalState`](/analytics/suggested-events/user-progression-global-state/) — Another suggested global state. --- ## UserProgressionGlobalState A [suggested event](/analytics/suggested-events/overview/) — define and log it yourself following the patterns in [Defining Custom Events](/analytics/defining-custom-events/). The shape below is the canonical contract. **Category:** Global state **Purpose** — A global state event carrying the user's current main-map level progression. Automatically attached to outgoing analytics events so any event can be segmented by progression. ## Fields ### `UserProgression` **Type:** `int` · **Required:** Yes · **Example:** `1710` User's current main-map level progression. ## C# example ```csharp using TactileModules.TactileAnalyticsModule; [TactileAnalytics.EventAttribute("UserProgressionGlobalState")] [EventCategory("Global state")] [EventDescription("Global state event carrying the user's current level progression. Attached to analytics events to make it trivial to segment any event by user progression.")] public class UserProgressionGlobalStateEvent { [EventPropertyDescription("User's current main-map level progression.")] private TactileAnalytics.RequiredParam UserProgression { get; set; } public UserProgressionGlobalStateEvent(int userProgression) { this.UserProgression = userProgression; } } ``` ## See also - [`LevelSessionIdGlobalState`](/analytics/suggested-events/level-session-id-global-state/) — Another suggested global state. - [`levelStarted`](/analytics/suggested-events/level-started/) — Progression typically advances when a level is completed within the main flow. --- ## Schema Generation — Remote Configuration Overview Remote configuration lets you change your game's behaviour parameters from the PlayOps dashboard without shipping a new app release. Configuration values are downloaded to the device at runtime and accessed through strongly-typed C# classes. ## How it works 1. You define configuration classes in C# and decorate them with `[Tactile.ConfigProvider("key")]`. 2. You bundle a default configuration JSON file with your game (in `Assets/[Configuration]/`). This is what players get on first launch or when offline. 3. At runtime, call `configurations.Sync()` to download the latest configuration from the server and update the local cache. 4. Call `configurations.GetConfiguration()` to access the current values as a typed object. 5. When you update configuration values in the PlayOps dashboard and publish them, players receive the new values on their next sync. ## Bundled default configuration You must include a default configuration JSON file in your game so players have working values on first launch or when offline. Place these files in `Assets/[Configuration]/` with one file per platform and environment: ``` Assets/ └── [Configuration]/ ├── configuration-ios-dev.json ├── configuration-ios-prod.json ├── configuration-android-dev.json └── configuration-android-prod.json ``` Each file contains the default values for all your configuration classes. The top-level keys must match the `key` argument in your `[ConfigProvider]` attributes: ```json { "ShopConfig": { "FeaturedProductId": "featured_item_123", "DiscountPercent": 0, "SaleActive": false, "Products": [ { "Id": "coins_100", "CoinsAmount": 100, "PriceUSD": 0.99 } ] }, "IAPConfig": { "InAppProducts": [ { "Identifier": "com.example.coins100", "Title": "100 Coins", "Price": 0.99, "CurrencyCode": "USD" } ] } } ``` The SDK includes build processors that handle configuration archiving automatically — you only need to create these JSON files. At build time, the SDK selects the correct file based on: - **Platform**: determined by Unity's active build target (iOS or Android) - **Environment**: determined by Unity's **Development Build** toggle — checked = `dev`, unchecked = `prod` These files are required for all builds, even if you don't use remote configuration yet. Without them, the build will fail. See [Defining Configuration Classes](/remote-configuration/defining-configuration-classes/) for details on how C# classes map to these JSON keys. ## Accessing the service ```csharp PlayOpsConfigurations configurations = sdk.Configurations(); ``` Always call `configurations.Initialize()` before accessing any configuration values. This loads the bundled fallback configuration into memory. ## Offline behaviour If a device has no network connection when `Sync()` is called, the SDK silently continues with the locally cached values. `Sync()` does not throw on network failure — it leaves the existing configuration intact. The first call to `GetConfiguration()` after `Initialize()` (without a prior `Sync()`) returns the values from the bundled JSON file that was included at build time. ## Events Subscribe to configuration events if you need to react when values change: ```csharp configurations.OnConfigurationDownloaded += (raw) => { Debug.Log("New configuration downloaded from server."); }; configurations.OnConfigurationUpdated += (raw) => { Debug.Log("Configuration applied. Refreshing UI."); RefreshShopPrices(); }; ``` --- ## Defining Configuration Classes Configuration classes are plain C# classes decorated with `[Tactile.ConfigProvider]`. The SDK uses these to deserialize server-side JSON into typed objects. ## Basic configuration class ```csharp using System.Collections.Generic; using ConfigSchema; [Tactile.ConfigProvider("IAPConfig")] public class IAPConfig { [JsonSerializable("InAppProducts", typeof(InAppProductInfo))] public List InAppProducts { get; set; } } ``` ## Nested types Nested types do not need a `[ConfigProvider]` attribute — only the root class does: ```csharp public class InAppProductInfo { [JsonSerializable("Identifier")] public string Identifier { get; set; } [JsonSerializable("Title")] public string Title { get; set; } [JsonSerializable("Price")] public double Price { get; set; } [JsonSerializable("CurrencyCode")] public string CurrencyCode { get; set; } } ``` ## Attribute reference ### `[Tactile.ConfigProvider(string key)]` Maps the class to a top-level JSON key in the server configuration. `key` must exactly match the key used in the dashboard configuration editor. ### `[JsonSerializable(string name)]` Maps a property to a JSON field name. `name` is the key as it appears in the JSON. ### `[JsonSerializable(string name, Type elementType)]` For `List` properties — the second parameter specifies the element type for deserialization. ## Supported field types | C# type | JSON type | |---------|-----------| | `string` | string | | `int` | number | | `double` | number | | `float` | number | | `bool` | boolean | | `List` | array | | Nested class | object | ## Full real-world example ```csharp using System.Collections.Generic; [Tactile.ConfigProvider("ShopConfig")] public class ShopConfig { [JsonSerializable("FeaturedProductId")] public string FeaturedProductId { get; set; } [JsonSerializable("DiscountPercent")] public int DiscountPercent { get; set; } [JsonSerializable("SaleActive")] public bool SaleActive { get; set; } [JsonSerializable("Products", typeof(ShopProductInfo))] public List Products { get; set; } } public class ShopProductInfo { [JsonSerializable("Id")] public string Id { get; set; } [JsonSerializable("CoinsAmount")] public int CoinsAmount { get; set; } [JsonSerializable("PriceUSD")] public double PriceUSD { get; set; } } ``` The corresponding JSON in your bundled configuration file would look like: ```json { "ShopConfig": { "FeaturedProductId": "featured_item_123", "DiscountPercent": 10, "SaleActive": true, "Products": [ { "Id": "coins_100", "CoinsAmount": 100, "PriceUSD": 0.99 }, { "Id": "coins_500", "CoinsAmount": 500, "PriceUSD": 3.99 } ] } } ``` The top-level key `"ShopConfig"` must match the string passed to `[ConfigProvider("ShopConfig")]`. Each property key must match the `name` argument in the corresponding `[JsonSerializable]` attribute. --- ## Accessing Configurations ## Initialize before access Always call `Initialize()` before reading any configuration values. This loads the bundled fallback configuration into memory so `GetConfiguration()` works even before the first sync: ```csharp var configurations = playOpsSDK.Configurations(); configurations.Initialize(); ``` ## GetConfiguration ```csharp var iapConfig = configurations.GetConfiguration(); foreach (var product in iapConfig.InAppProducts) { Debug.Log($"{product.Title}: ${product.Price}"); } ``` `GetConfiguration()` returns the cached instance of `T`. The first call returns the bundled default. After a successful `Sync()`, it returns the downloaded values. Do not call `GetConfiguration()` before `Initialize()`. The bundled configuration will not be loaded and you will get null or empty values. ## Reset Clears the in-memory cache and removes the locally persisted configuration. The next call to `GetConfiguration()` after `Initialize()` will again return the bundled default: ```csharp configurations.Reset(); configurations.Initialize(); ``` Useful in testing or when you want to force a clean state. ## GetMetadataVersion Returns the version metadata of the currently active configuration: ```csharp var version = configurations.GetMetadataVersion(); Debug.Log($"Config version: {version.Version}, environment: {version.Environment}"); ``` This is useful for debugging — you can log the version on session start to verify which configuration a player is running. --- ## Syncing at Runtime ## Sync `Sync()` downloads the latest configuration from the server and immediately applies it to the local cache. It is a coroutine — use `yield return` to wait for it to complete: ```csharp private IEnumerator SyncOnStart() { var configurations = playOpsSDK.Configurations(); configurations.Initialize(); yield return configurations.Sync(); // Configuration is now up-to-date var shopConfig = configurations.GetConfiguration(); Debug.Log($"Shop has {shopConfig.Products.Count} products."); } ``` Call `Sync()` early in the session — before the player enters any gameplay that depends on configuration values. The most common pattern is to sync during the loading screen on app start. ## Download without applying `Download()` fetches the latest configuration from the server but does not update the active cache. The downloaded data is delivered via the `OnConfigurationDownloaded` event. Subscribe to it and call `Update()` with the received data when you are ready to apply — for example, between levels to avoid mid-gameplay changes: ```csharp configurations.OnConfigurationDownloaded += rawConfig => { // Store and apply later (e.g. between levels) configurations.Update(rawConfig); }; yield return configurations.Download(); ``` ## Manually applying a configuration `Update(Hashtable)` applies raw configuration data directly: ```csharp // Apply a Hashtable of configuration data (e.g. from a test fixture) configurations.Update(rawConfiguration); ``` Calling `Update()` without an argument is a no-op — you must pass the hashtable. ## Full recommended pattern ```csharp public class SessionBootstrap : MonoBehaviour { void Start() { StartCoroutine(Boot()); } private IEnumerator Boot() { var configurations = playOpsSDK.Configurations(); configurations.Initialize(); // Show loading screen LoadingScreen.Show(); yield return configurations.Sync(); // Configuration is fresh — safe to read values ApplyConfiguration(configurations); LoadingScreen.Hide(); } private void ApplyConfiguration(PlayOpsConfigurations configurations) { var shopConfig = configurations.GetConfiguration(); ShopController.Instance.SetProducts(shopConfig.Products); var iapConfig = configurations.GetConfiguration(); IAPManager.Instance.SetProducts(iapConfig.InAppProducts); } } ``` ## Network failure handling `Sync()` does not throw if the server is unreachable. When the network request fails, the coroutine completes normally and the local configuration (bundled default or previously downloaded) remains active. Your game continues to function with the last known-good values. Do not `try/catch` around `yield return configurations.Sync()` expecting a thrown exception on failure — it will not be thrown. Subscribe to `OnConfigurationDownloaded` if you need to know whether a download succeeded. --- ## Schema Generation The SDK includes a build processor that automatically generates a `configuration-schema.json` file every time you build your game. This schema describes every configuration module in your project and is what the LiveOps Dashboard uses to render the configuration editor. ## What is generated The `configuration-schema.json` file includes a JSON representation of every class decorated with `[Tactile.ConfigProvider]`. For example, given the following C# class: ```csharp [Tactile.ConfigProvider("AppTracking")] public class AppTrackingConfig { [Description("iOS version from which the App Tracker dialog should show.")] [JsonSerializable("EnableFromIOSVersion")] public string EnableFromIOSVersion { get; set; } } ``` The generated schema will contain an `AppTracking` entry with a `string` field for `EnableFromIOSVersion`. This is what the Dashboard uses to render the editing UI for that module. ## When it runs The schema is generated automatically as a post-process step after every Unity build. ## Exporting without a build You can also export the schema on demand from the Unity Editor without performing a full build: 1. Open **Tactile → Export → Configuration Schema** from the menu bar. 2. The schema file is written to your project directory. This is useful for quick iteration — you can verify your configuration classes are correct before committing to a full build. ## What to do with the schema file After generating the schema, upload it to the LiveOps Dashboard. See [Uploading Schemas](/remote-configuration/uploading-schemas/). --- ## Uploading Schemas to the Dashboard The LiveOps Dashboard uses the uploaded configuration schema to render the module editor. Before you can configure values for a module, you must upload the generated schema file. You must re-upload whenever you add, rename, or remove configuration classes or properties. If you do not have access to the LiveOps Dashboard, contact your Tactile integration contact to upload the schema file on your behalf. ## Uploading 1. Open the **Configurations** section for your game in the LiveOps Dashboard. 2. Upload the `configuration-schema.json` file to the appropriate configuration module. 3. Create a configuration module version and assign a **version range** that matches the game versions this schema is valid for. You are responsible for ensuring the uploaded schema is valid for the game version the configuration targets. If the schema does not match the configuration classes in the game client, the client may fail to parse the configuration. ## Configuration modules In the Dashboard, configuration is organized into **modules**. Each module corresponds to a `[Tactile.ConfigProvider]` class in your code. When you upload a schema, each annotated class appears as a separate module that you can edit and publish independently. ## Module versioning Each module defines its own list of **compatible version ranges**. Different version ranges can have: - Different configuration **values** (e.g. a feature enabled in `1.90.0+` but disabled in earlier versions) - Different configuration **schemas** (e.g. a config class gains a new field in `1.30.0+`) When creating or updating a module version, you specify the version range it applies to. Game clients receive the configuration that matches their version. ## Publishing After configuring your module values, publish them to make them available to game clients. Published configuration is served by the Game Webservice and delivered to clients on their next [Sync](/remote-configuration/syncing-at-runtime/). ## When to upload | Situation | Upload required? | |-----------|-----------------| | New `[ConfigProvider]` class added | Yes | | Existing module: new property added | Yes | | Existing module: property removed | Yes | | Existing module: property type changed | Yes | | Module class renamed | Yes | | Only configuration values changed (no schema change) | No — edit values directly in the Dashboard | | `[Description]` text changed | No — descriptions are informational only | ## Deprecating modules If a feature is removed from your game, you can mark its configuration module as **deprecated** in the Dashboard. This is informational only — the module remains available for older game versions that still use it. --- ## Build Server — Build Server Overview The [Build Server](https://tactile.build/) is the central UI for ordering and monitoring builds. It reads a project's `build_config.json` to determine which builds are available, and exposes them through an "Order new" screen. ## What the Build Server does - Exposes all non-hidden builds defined in a project's `build_config.json` - Lets users select a project, branch, revision, and build target - Dispatches builds to available [build agents](https://github.com/tactileentertainment/build-tools.build-agent) - Displays real-time build progress from `build_result.json` - Manages [scheduled builds](https://tactile.build/scheduled-builds) (nightly builds, recurring builds) ## Scheduled builds Scheduled builds are configured in the [Scheduled Builds UI](https://tactile.build/scheduled-builds). Game builds run **nightly** for all platforms. ## Get started New to the Build Server? Start with the [Quick Start](/build-server/quick-start/) guide — download a starter template and order your first build in minutes. ## Learn more - [Ordering Builds](/build-server/ordering-builds/) — how to request game builds - [Release Candidates](/build-server/release-candidates/) — release workflow and app store uploads - [In-house Developer Builds](/build-server/developer-builds/) — testing with developer versions - [Build Server Protocol](/build-server/protocol/) — `build_config.json` and `build_result.json` reference --- ## Quick Start The fastest way to get started with the Build Server is to use the starter template. It includes all the files you need to order your first build. ## Download the starter template Download Build Starter Template (.zip) The template contains: | File | Purpose | |------|---------| | `Build/build_config.json` | Tells the Build Server what builds your project supports | | `Build/Pipeline/nuget.yml` | Plugin dependencies (Unity, iOS, AppSigning, SaveArtifacts) | | `Build/Pipeline/definitions/` | Pipeline definitions for Android and iOS | | `Build/Pipeline/values/` | Shared configuration values per platform | ## Setup ### 1. Copy files to your project Copy the `Build/` folder to your game repository root: ``` your-game/ ├── Build/ │ ├── build_config.json │ └── Pipeline/ │ ├── nuget.yml │ ├── definitions/ │ │ ├── android/ │ │ │ └── store.yml │ │ └── ios/ │ │ └── store.yml │ └── values/ │ ├── product.yml │ ├── android/ │ │ └── unity.yml │ └── ios/ │ ├── unity.yml │ ├── xcode.yml │ └── signing.yml ├── Assets/ ├── Packages/ └── ProjectSettings/ ``` ### 2. Configure build_config.json Update `requirements` to match your project's tooling: ```json "requirements": { "xcode": "26.2", "unity": "6000.0.47f1" } ``` ### 3. Review the build plugins The starter template comes preconfigured with these Build Plugins in `Build/Pipeline/nuget.yml`: - **`BuildPlugins.Unity`** — Runs the Unity Editor to build your game - **`BuildPlugins.IOS`** — Builds the Unity Xcode project into a signed IPA - **`BuildPlugins.AppSigning`** — Checks out iOS provisioning profiles and certificates - **`BuildPlugins.SaveArtifacts`** — Uploads build outputs to the Build Server You don't need to change anything here unless you want to add more plugins. See [Build Plugins](/build-server/build-plugins/) for details. ### 4. Create a Unity build script Your Unity project needs a static C# method that the pipeline calls to build the game. See [Unity Build Script](/build-server/build-script/) for a complete example. The method name must match the `UNITY_EXECUTE_METHOD` value in your pipeline values files. ### 5. Configure values Edit the value files in `Build/Pipeline/values/`: | File | What to set | |------|-------------| | `product.yml` | Product name and bundle identifier | | `android/unity.yml` | Android Unity build target and build script method | | `ios/unity.yml` | iOS build script method | | `ios/signing.yml` | iOS provisioning profile and signing certificate | ### 6. Commit and order your first build Commit the files to your repository, then go to the [Build Server](https://tactile.build/): 1. Click **Order build** 2. Select your project, branch, and revision 3. Choose a build target (e.g. **Android**) Start with an Android build to verify everything works — it has the simplest pipeline with no code signing requirements. ## What's next - [Ordering Builds](/build-server/ordering-builds/) — details on build types and options - [Pipeline Definition](/build-pipeline/pipeline-definition/) — customize your pipeline steps - [Plugins & Dependencies](/build-pipeline/plugins/) — add more build plugins - [Protocol Reference](/build-server/protocol/) — full `build_config.json` schema --- ## Unity Build Script The Build Pipeline uses `UNITY_EXECUTE_METHOD` to invoke a static C# method that builds your game. You need to create this method in an **Editor** script in your Unity project. ## What the build script does When the Build Server runs your pipeline, it launches Unity in batch mode and calls your build method. Your method is responsible for: 1. Configuring player settings (product name, bundle identifier, version) 2. Gathering the scenes to include 3. Calling `BuildPipeline.BuildPlayer()` with the correct target and output path 4. Exiting with code `1` on failure so the pipeline knows the build failed ## Example build script Create this file at `Assets/Build/Editor/BuildProduct.cs` in your Unity project: ```csharp using System.Linq; using UnityEditor; using UnityEditor.Build.Reporting; using UnityEngine; namespace YourGame { public static class BuildScript { [MenuItem("YourGame/Build/Build Android")] public static void BuildProduct() { EditorUserBuildSettings.SwitchActiveBuildTarget( BuildTargetGroup.Android, BuildTarget.Android); PlayerSettings.companyName = "Your Studio"; PlayerSettings.productName = "YourGame"; PlayerSettings.bundleVersion = "1.0.0"; PlayerSettings.Android.bundleVersionCode = 1; var scenes = EditorBuildSettings.scenes .Where(s => s.enabled) .Select(s => s.path) .ToArray(); if (scenes.Length == 0) { Debug.LogError("No scenes enabled in Build Settings."); EditorApplication.Exit(1); return; } var options = new BuildPlayerOptions { scenes = scenes, locationPathName = "Builds/Android/YourGame.apk", target = BuildTarget.Android, options = BuildOptions.None }; var report = BuildPipeline.BuildPlayer(options); if (report.summary.result != BuildResult.Succeeded) { Debug.LogError($"Build failed: {report.summary.result}"); EditorApplication.Exit(1); } } [MenuItem("YourGame/Build/Build iOS")] public static void BuildProductiOS() { EditorUserBuildSettings.SwitchActiveBuildTarget( BuildTargetGroup.iOS, BuildTarget.iOS); PlayerSettings.companyName = "Your Studio"; PlayerSettings.productName = "YourGame"; PlayerSettings.bundleVersion = "1.0.0"; PlayerSettings.iOS.buildNumber = "1"; var scenes = EditorBuildSettings.scenes .Where(s => s.enabled) .Select(s => s.path) .ToArray(); if (scenes.Length == 0) { Debug.LogError("No scenes enabled in Build Settings."); EditorApplication.Exit(1); return; } var options = new BuildPlayerOptions { scenes = scenes, locationPathName = "Builds/iOS", target = BuildTarget.iOS, options = BuildOptions.None }; var report = BuildPipeline.BuildPlayer(options); if (report.summary.result != BuildResult.Succeeded) { Debug.LogError($"Build failed: {report.summary.result}"); EditorApplication.Exit(1); } } } } ``` The file must be inside an `Editor` folder (e.g. `Assets/Build/Editor/`) so it is only included in the Unity Editor and not in your game's runtime build. ## Connecting to the pipeline The pipeline calls your build method via the `UNITY_EXECUTE_METHOD` value. In your pipeline values file (`Build/Pipeline/values/android/unity.yml`), set it to the fully qualified method name: ```yaml values: UNITY_EXECUTE_METHOD: "YourGame.BuildScript.BuildProduct" ``` For iOS (`Build/Pipeline/values/ios/unity.yml`): ```yaml values: UNITY_EXECUTE_METHOD: "YourGame.BuildScript.BuildProductiOS" ``` The pipeline's `UnityCustomBuildStep` launches Unity in batch mode with `-executeMethod` pointing to this value. ## Testing locally You can test your build script from the Unity Editor menu (the `[MenuItem]` attributes) or from the command line: ```bash # Android /Applications/Unity/Hub/Editor/6000.0.47f1/Unity.app/Contents/MacOS/Unity \ -batchmode -quit -projectPath /path/to/your/project \ -executeMethod YourGame.BuildScript.BuildProduct # iOS /Applications/Unity/Hub/Editor/6000.0.47f1/Unity.app/Contents/MacOS/Unity \ -batchmode -quit -projectPath /path/to/your/project \ -executeMethod YourGame.BuildScript.BuildProductiOS ``` ## Important notes - **Exit code** — Always call `EditorApplication.Exit(1)` on failure. The pipeline detects non-zero exit codes as build failures. - **Scenes** — Ensure your scenes are enabled in **Build Settings** (`File > Build Settings`). The build script reads from `EditorBuildSettings.scenes`. - **Output path** — Android builds produce an `.apk` file. iOS builds produce an Xcode project directory that the pipeline's `IOSBuildStep` processes further. - **Build options** — Use `BuildOptions.None` for production builds. Use `BuildOptions.Development` for development builds with debugging enabled. --- ## Build Plugins Reference The starter template uses these Build Plugins: - **`BuildPlugins.Unity`** — Runs the Unity Editor in batch mode to build your game. Provides Unity activation, project settings, and custom build execution steps. Used by both Android and iOS pipelines. - **`BuildPlugins.IOS`** — Builds the Unity-generated Xcode project into a signed IPA using `xcodebuild`. - **`BuildPlugins.AppSigning`** — Checks out iOS provisioning profiles and signing certificates required by the iOS build step. - **`BuildPlugins.SaveArtifacts`** — Uploads build outputs (APK, IPA, logs) to the Build Server so they can be downloaded. ## Adding more plugins If you need functionality beyond the starter template (e.g. versioning, preprocessor directives, string replacements), add them to `nuget.yml`. See [Plugins & Dependencies](/build-pipeline/plugins/) for details. --- ## Ordering Builds All builds are ordered through the [Build Server UI](https://tactile.build/) or triggered automatically by other builds. The Build Server only runs builds listed in a project's `build_config.json`. ## Game builds A full build of a game for a specific **platform** (iOS, Android, Kindle) and **type** (store or inhouse). 1. Choose a project, branch, and revision. 2. Select a supported build target (e.g. **Android (Inhouse)** or **Kindle (Store)**). | Tab | `kind` | `type` | |-----|--------|--------| | Build | `build` | `inhouse`, `store` | Game builds also run **nightly** for all platforms and types. :::tip Save frequently used build configurations as **favorites** on the Build Server. Click **Save** after setting up the details — favorites appear on the left of the new build page. ::: --- ## Release Candidates ## What is a release candidate Builds are automatically marked as **release candidates** (highlighted in green) when they meet all of the following conditions: - Built from a **release branch** - **Store** build type - No custom precompiled flags ## Uploading to app stores Release candidate builds have an **Upload to app store** button that triggers a separate `upload` build. | Tab | `kind` | `type` | |-----|--------|--------| | Custom | `custom` | `upload` | ### Supported stores The App Uploader supports: - **Apple App Store** (iOS) - **Google Play Store** (Android) - **Amazon** (Kindle) ### Release notes Release notes can be predefined at [tactile.build/projects/release-notes](https://tactile.build/projects/release-notes) and uploaded alongside builds. --- ## In-house Developer Builds To test in a development environment, request an **in-house build** of your branch. In-house builds: - Read from the Game Webservice **development** environment - Are built with the `TACTILE_DEVELOPER_BUILD` flag, enabling dev-specific features like detailed error messages By convention, developers use the **developer version convention** (`999.X.X`) so that LiveOps Dashboard resources created for development are not accidentally served to QA or players. ## Ordering a developer build 1. Log in to the [Build Server](https://tactile.build/). 2. Click **Order build**. 3. Set the **Version** to your developer version (e.g. `999.24.9`). 4. Under **Supported builds**, select **999.X.X (in-house)**. ## Installing the build 1. Click the completed build, then click the **share icon** to display the QR code. 2. Scan the QR code with your device and install the game. 3. Verify the configuration version in the game's Settings menu. ## Targeting developer builds in the Dashboard You can assign a **target version or version range** to configuration modules, scheduled features, asset bundles, and A/B tests. Using the `999.X.X` convention ensures development resources are never served to QA or players. 1. Create a configuration module version and assign a version range following the convention (e.g. `999.24.9`). 2. Make your changes to the module. 3. **Publish to the Development environment** — in-house builds only read from Development. --- ## Build Server Protocol For the Build Server controller to work with a game project, two files are required: `build_config.json` (input) and `build_result.json` (output). ## build_config.json Defined in the `Build/` folder of your game project repository **before a build can be ordered**. The Build Server controller fetches this file based on the selected game, branch, and revision, and uses it to render the "Order new" UI. ### builds A required list of supported build configurations. | Key | Required | Type | Description | |-----|----------|------|-------------| | `id` | Yes | string | Unique identifier for the build configuration | | `display_name` | Yes | string | Human-readable name shown in the UI | | `builder` | Yes | string | File that kickstarts the client builder | | `pipeline` | No | string | Pipeline definition YAML to execute | | `platform` | Yes | string | Target platform (ios, android, kindle) | | `kind` | Yes | string | Build kind (build, bundle, cache, custom) | | `type` | Yes | string | Build type (inhouse, store, beta, qa, upload) | | `configuration_file` | No | string | Configuration file name (e.g. `ios_inhouse`) | | `requirements` | No | object | Build-specific requirements that override root-level values | | `hidden` | No | boolean | If `true`, hides this build from the UI | ### settings UI elements for customising build parameters when ordering. Each setting is defined with a `{setting_name}` key (must be `snake_case`). | Key | Required | Type | Description | |-----|----------|------|-------------| | `display_name` | Yes | string | Label shown in the UI | | `type` | Yes | string | Input type (see below) | | `group` | No | string | Groups UI elements together (e.g. "Advanced", "Settings") | | `values` | No* | string[] | Options for checkbox, radio, or select types | | `multi` | No | boolean | Enable multi-select (for select/checkbox types) | | `default` | No | any | Default value | | `required` | No | boolean | Must have a value before ordering | | `hidden` | No | boolean | Hidden from UI (useful for API-only settings) | **Supported input types:** | Type | Description | |------|-------------| | `checkbox` | Checkbox (single or multiple with `values`) | | `radio` | Radio buttons (always requires `values`) | | `select` | Dropdown (requires `values`) | | `number` | Numeric input | | `text` | Text input | | `switch` | Toggle (true/false) | ### requirements Tools and versions required on the build agent. Can be defined at root level or per-build (build-level overrides root). ```json "requirements": { "unity": "6000.0.23f1" } ``` The `{requirement}` key must match the `supported_tools` attribute exposed by the build agent. ### actions Post-build actions that can be triggered automatically or manually. See the [Actions section](#actions) below. ### build_pipeline Instructs the agent to checkout a specific branch of the build pipeline. ```json "build_pipeline": { "branch": "v1" } ``` ### Example ```json { "builds": [ { "id": 0, "display_name": "iOS (store)", "builder": "some_path/ios_magic_build.sh", "platform": "ios", "kind": "build", "type": "store" }, { "id": 1, "display_name": "Android (store)", "builder": "another_path/build.py", "pipeline": "Build/Pipeline/definitions/android/store.yml", "platform": "android", "kind": "build", "type": "store", "requirements": { "sdk": "1.2.3" } } ], "settings": { "skip_tests": { "display_name": "Skip Tests", "type": "switch", "group": "Settings" }, "precompiler_directives": { "display_name": "Precompiler Directives", "type": "select", "group": "Advanced", "multi": true, "values": ["enable_cheat", "enable_debug_logs", "disable_skip"] } }, "requirements": { "unity": "6000.0.23f1" }, "version": 1, "build_pipeline": { "branch": "v1" } } ``` ## build_result.json Generated by the build process to communicate status back to the controller. It should be updated **progressively** as the build runs, not just at the end. ### Top-level fields | Key | Required | Type | Description | |-----|----------|------|-------------| | `version` | Yes | number | Schema version | | `main_artifact` | No | artifact | Downloadable build (used for the download/install button) | | `steps` | Yes | step[] | List of build steps with status | | `version_name` | Yes | string | Resolved marketing version | | `product_name` | No* | string | App title as installed (*required for iOS) | | `package_name` | No* | string | Bundle identifier (*required for iOS) | | `version_code` | No* | string | Bundle version, usually SVN revision (*required for iOS) | | `commits` | No | artifact | Artifact containing the commit file | | `changelog` | No | artifact | Artifact containing the changelog | | `release_candidate` | No | boolean | Whether this build can be uploaded to stores | ### step | Key | Required | Type | Description | |-----|----------|------|-------------| | `name` | Yes | string | Step name | | `state` | Yes | string | `pending`, `running`, `skipped`, `failed`, or `done` | | `start` | No | string | Timestamp when the step started | | `end` | No | string | Timestamp when the step ended | | `log_file` | No | artifact | Log file for this step | | `artifacts` | No | artifact[] | Artifacts produced by this step | | `warnings` | No | warning[] | Warnings generated by this step | ### artifact | Key | Required | Type | Description | |-----|----------|------|-------------| | `file` | Yes | string | Path under `artifacts/` with extension | | `group` | No | string | Grouping label | | `size` | No | string | File size in bytes | | `type` | No | string | MIME type | ### warning | Key | Required | Type | Description | |-----|----------|------|-------------| | `message` | Yes | string | Description of the warning | | `level` | Yes | string | `info`, `warning`, or `error` | ## Actions Actions are defined in the `actions` array of `build_config.json`. They trigger HTTP requests either automatically after a successful build or manually from the build detail page. ### Action properties | Key | Required | Type | Description | |-----|----------|------|-------------| | `name` | Yes | string | Unique identifier | | `display_name` | Yes | string | Button label in the UI | | `trigger` | Yes | enum | `automatic` (after build succeeds) or `manual` | | `hidden` | No | boolean | Hide from the UI actions list | | `available_states` | No | string[] | States where the action is available (`finished`, `cancelled`, `running`, `queued`, `failed`). Defaults to after success only. | | `prerequisites` | No | object | Key-value filters to match builds (e.g. `"releaseCandidate": true`) | | `request` | Yes | object | HTTP request with template variables | | `settings` | No | object | Input fields shown in a dialog before triggering | ### Template variables Actions support template variables that are replaced at runtime: - `<>`, `<>`, `<>`, `<>` - `<>` - `<>` - `<>` --- ## Build Pipeline — Build Pipeline Overview The client build pipeline is a configurable, plugin-based build system written in C# (.NET Core). Each project configures its own pipeline from individual plugins distributed as NuGet packages, making steps easy to share, test, and reuse across teams. ## Architecture The pipeline has four main components: ### BuildPipeline The entry point for running builds. Provides services for: - Retrieving build input (the "build order") - Writing to build output, issuing warnings/errors, archiving artifacts - Passing data between build plugins Internally split into two projects: - **ProjectRunner** — Entry point. Loads dependencies from the pipeline definition, sets up NuGet packages, then compiles and launches the PipelineRunner. - **PipelineRunner** — The pipeline engine. Loads steps from the definition, iterates and runs each step, handles teardowns, and manages the overall pipeline flow. ### TactilePipeline A shared NuGet package containing the interfaces and objects shared between the BuildPipeline and build plugins. Acts as a stable API contract that plugins depend on, ensuring it won't change frequently. ### Pipeline Definition A YAML configuration file that specifies which steps to run and in what order. It also declares pipeline values and plugin dependencies. See [Pipeline Definition](/build-pipeline/pipeline-definition/) for details. ### Steps Operations executed by the PipelineRunner. Each step performs a specific task — from uploading a file, to building Unity, to modifying a configuration. See [Steps](/build-pipeline/steps/) for the different step types. ## When to use what Before creating a new build component, consider: - **Unity BuildProcessor** — If the task is part of Unity's code or related to a module like configuration. - **Script Step** — If it's a simple one-line operation (copying/moving/deleting files, running a command). - **Build Plugin** — If it's a complex system that needs to be shared with others. ## Next steps - [Pipeline Definition](/build-pipeline/pipeline-definition/) — how to configure your build pipeline - [Steps](/build-pipeline/steps/) — build plugin steps, script steps, and includes - [Plugins & Dependencies](/build-pipeline/plugins/) — managing NuGet dependencies - [Communication Between Steps](/build-pipeline/communication/) — how steps share data --- ## Pipeline Definition The **pipeline definition** is a YAML configuration file that specifies which steps to run, in what order, along with the values and plugin dependencies needed. It is the file the Pipeline Runner reads to understand the full build sequence. ## Structure A pipeline definition has three sections: ```yaml # PIPELINE VALUES values: - android - unity # PLUGINS plugins: - name: BuildPlugins.ProjectVersioning # PIPELINE STEPS steps: - name: Project Versioning run: stepId: VersioningStep - name: Run Editor Tests run: script: ./BuildPipeline/scripts/run_unity_tests_step ``` ### values References to YAML value files that provide configuration data to build steps. See [Communication Between Steps](/build-pipeline/communication/#pipeline-values) for details. ### plugins The Build Plugin NuGet packages used in this pipeline. These can reference dependencies defined in `nuget.yml` or use [grouped dependencies](/build-pipeline/plugins/#grouped-dependencies) inline. ```yaml plugins: - name: BuildPlugins.ProjectVersioning - name: BuildPlugins.CreatePlatformConfig ``` ### steps The ordered list of operations the pipeline executes. Each step can run either a [Build Plugin Step](/build-pipeline/steps/#build-plugin-step) or a [Script Step](/build-pipeline/steps/#script-step). ## Step properties | Property | Required | Description | |----------|----------|-------------| | `name` | Yes | Display name rendered in the Build Server UI | | `allowToFail` | No | If `true`, the pipeline continues even if this step fails (default: `false`) | | `run` | Yes | What to execute — either `stepId` (Build Plugin) or `script` (file path) | | `teardown` | No | Cleanup script or Build Plugin teardown to run when the pipeline finishes | ```yaml steps: - name: "Build and Test" run: script: "scripts/build-and-test" teardown: script: "scripts/cleanup" - name: "Deploy" allowToFail: true run: stepId: "DeployStep" ``` Because the pipeline is defined as a configuration of individual steps, you can: - Render the full build sequence up front in the UI - Skip any step without special flags - Create custom pipelines by rearranging available steps --- ## Steps Steps are operations executed by the Pipeline Runner. Each step performs a specific task, and all steps together form the pipeline. A step should be as focused as possible — the more specific it is, the more shareable it becomes. ## Build Plugin Step A C# NuGet package with full access to the pipeline API (build arguments, build result, data passing between steps). This is the **recommended approach** for most use cases. A Build Plugin can contain multiple related steps. For example, `build-plugins.unity` contains `ActivateStep`, `UnityBuildStep`, and `RunUnitTestsStep`. ```csharp public class MyPluginStep : IStep { public string StepId => "MyPluginStep"; public async Task Run(IPipelineService pipelineService) { // Task code here } } ``` Referenced in the pipeline definition by `stepId`: ```yaml steps: - name: "My Plugin" run: stepId: "MyPluginStep" ``` | Benefits | Downsides | |----------|-----------| | C# — familiar to game developers | More complex development/publishing workflow | | NuGet package management | Requires CI for automated releases | | Independent, isolated projects | | | Built-in pipeline API for common operations | | | Easy to test locally and share across teams | | | Code is not part of the game project | | ## Script Step Any script supported by the build agent environment (bash, Python, Node, Ruby, etc.). Simpler to set up but lacks the pipeline API — information is passed as command-line arguments via `--values`. ```yaml steps: - name: "Hello World" run: script: "scripts/hello_world.py" ``` ```python #!/usr/bin/env python if __name__ == "__main__": print("Hello World from Python!") print(str(sys.argv)) ``` | Benefits | Downsides | |----------|-----------| | No complicated publishing workflow | No pipeline API for common operations | | Easy for simple actions | Unfamiliar to game programmers | | Any script language supported | Scripts are embedded in the game project | :::note Scripts cannot write to the build result. Use scripts for prototyping or small tasks. For anything more complex, use a Build Plugin Step. ::: ## Include Steps can be split across multiple files using `include`, allowing you to share common step sequences between pipeline definitions. | Benefits | Downsides | |----------|-----------| | Split large pipeline files into smaller ones | Can reduce readability if too granular | | Share steps between pipeline definitions | | ## Teardowns Steps can define a `teardown` section for cleanup: - For **Script Steps**, specify a cleanup script path. - For **Build Plugin Steps** that implement the `ITeardown` interface, the teardown method is called automatically — no explicit declaration needed. ```yaml steps: - name: "Build" run: script: "scripts/build" teardown: script: "scripts/cleanup" ``` Teardowns run when the pipeline finishes, regardless of whether the step succeeded or failed. --- ## Plugins & Dependencies The build pipeline uses C# Build Plugins distributed as NuGet packages. To add plugins, define them in a `nuget.yml` file under `Build/Pipeline/`. ## Project structure ``` my_project/ ├── Build/ │ ├── Pipeline/ │ │ ├── values/ │ │ │ ├── android/ │ │ │ │ └── gradle.yml │ │ │ └── product.yml │ │ └── nuget.yml ├── src/ │ ├── ... └── ... ``` ## Defining dependencies List your NuGet packages in `nuget.yml` under the `dependencies` section: ```yaml dependencies: - name: BuildPlugins.Unity version: 1.* - name: BuildPlugins.Android version: 1.* - name: BuildPlugins.IOS version: 1.* - name: BuildPlugins.SaveArtifacts version: 1.* ``` You can also reference a local `.csproj` or a Git repository instead of a published version: ```yaml dependencies: - name: BuildPlugins.Android local: "path/to/my/project/Android.csproj" - name: BuildPlugins.IOS git: url: https://github.com/tactilegames/build-plugins.ios branch: my-branch - name: BuildPlugins.Unity git: url: https://github.com/tactilegames/build-plugins.unity commit: c92c4c52fa9cde0a9f2c66166f2b9957c2fc44a3 ``` ### Source types | Source | Description | |--------|-------------| | `version` | Pulls from the NuGet registry. Supports wildcards — `1.*` gets the latest within major version 1, `1.1.*` gets the latest within 1.1.x. May include pre-release labels (`-alpha`, `-beta`, `-rc`). | | `local` | Direct reference to a `.csproj` file. Changes are reflected on the next pipeline run — useful for development and testing. | | `git` | Clone from a Git repository by `branch` or `commit` hash. Useful when a NuGet package isn't published yet. | :::tip Use `1.*` for version to automatically get new features and fixes without modifying `nuget.yml`. ::: :::note If both `version` and `local` are specified for the same dependency, `local` takes precedence. ::: ## Configuring plugins in the pipeline Reference plugins by their NuGet package name in the [pipeline definition](/build-pipeline/pipeline-definition/): ```yaml plugins: - name: BuildPlugins.Unity - name: BuildPlugins.Android - name: BuildPlugins.SaveArtifacts ``` ## Grouped dependencies For simpler setups where you have few pipelines or don't typically share Build Plugins, you can declare dependencies inline using `dependency.name:version`: ```yaml plugins: - name: BuildPlugins.Unity:1.0.0 - name: BuildPlugins.Android:1.0.0 ``` This avoids creating a separate `nuget.yml` file by combining the dependency declaration and version in one line. --- ## Communication Between Steps Steps in the build pipeline can share data through three mechanisms. ## build_result.json The shared protocol between the build agent and the pipeline. The current state of `build_result.json` is available through the pipeline API to any Build Plugin step. Information written by an earlier step can be accessed by later steps — for example, which step failed, how many warnings there are, and so on. :::note The pipeline maintains `build_result.json` — individual steps do not write to it directly. Only Build Plugin Steps (not Script Steps) have API access to read from it. ::: ## Build artifacts All steps can produce artifacts, and some steps depend on artifacts from earlier steps. For example: - The **Unity build step** produces a Gradle project - The **Android Gradle build step** relies on that output to exist Step ordering in the [pipeline definition](/build-pipeline/pipeline-definition/) controls execution order. It is up to each step to handle missing prerequisites — there is currently no way to express artifact dependencies in the definition, so the step should inform the user if something is missing. ## Pipeline Values Pipeline values are the **primary mechanism** for passing configuration to build plugins. They are loaded from YAML files referenced in the `values` section of the pipeline definition. ```yaml values: - android - unity ``` Each entry references a YAML file under `Build/Pipeline/values/`. Steps can request both **mandatory** and **optional** pipeline values to start their task. If a mandatory value is missing, the step will fail. Pipeline values allow you to: - Centralise configuration shared across multiple steps - Vary configuration per platform or build type - Pass build parameters from the Build Server order to individual steps --- ## Reference — PlayOpsSDK The root entry point for all PlayOps services. ## Constructors ### `PlayOpsSDK(PlayOpsSettings, params ServiceSettings[])` Creates the SDK instance without an existing DI container. Pass one or more `ServiceSettings` (e.g. `AnalyticsSettings`). ```csharp var sdk = new PlayOpsSDK( new PlayOpsSettings(cloudUrl: "...", gameSecret: "..."), new AnalyticsSettings(appId: "...") ); ``` ## Methods | Method | Returns | Description | |--------|---------|-------------| | `Initialize()` | `void` | Starts all background services. Call once in `Awake` before accessing any service. | | `GetService()` | `T` | Returns a registered service by type. Throws `PlayOpsNotInitializedException` if called before `Initialize()`. | ## Extension methods These convenience accessors are extension methods. Import the corresponding namespace to use them. | Method | Namespace | Returns | |--------|-----------|---------| | `sdk.Analytics()` | `PlayOps.Analytics` | `PlayOpsAnalytics` | | `sdk.Configurations()` | `PlayOps.Configurations` | `PlayOpsConfigurations` | ## Exceptions **`PlayOpsNotInitializedException`** — thrown when accessing a service before calling `Initialize()`. **`PlayOpsServiceNotFoundException`** — thrown when requesting a service type that is not registered (e.g. a service whose assembly is not in the project). --- ## PlayOpsSettings Configuration class for the core PlayOps SDK connection settings. ## Constructor ```csharp new PlayOpsSettings(string cloudUrl, string gameSecret) ``` ## Fields | Field | Type | Default | Description | |----------|------|---------|-------------| | `CloudUrl` | `string` | — | Base URL of your game's PlayOps backend. Provided by Tactile. Example: `https://my-game.tactilews.com` | | `SharedGameSecret` | `string` | — | UUID that uniquely identifies your game. Used for request authentication. Set via the `gameSecret` constructor parameter. | | `DefaultRequestTimeoutInSeconds` | `int` | `10` | Timeout for background, non-interactive HTTP requests. | | `InteractiveRequestTimeoutInSeconds` | `int` | `30` | Timeout for player-triggered requests such as configuration sync on session start. | --- ## AnalyticsSettings Configuration class for the analytics service. ## Constructor ```csharp new AnalyticsSettings(string appId) ``` ## Properties | Property | Type | Default | Description | |----------|------|---------|-------------| | `AppId` | `string` | — | Analytics namespace. Determines which project in the dashboard receives events. Example: `my-game-prod` | --- ## PlayOpsAnalytics The analytics service. Access via `sdk.Analytics()`. ## Methods ### `LogEvent(object eventObject)` Logs an analytics event. `eventObject` must be an instance of a class decorated with `[TactileAnalytics.EventAttribute]`. ```csharp sdk.Analytics().LogEvent(new LevelCompleteEvent("level_01")); ``` ### `LogPurchase(Transaction transaction)` Logs an in-app purchase for server-side receipt validation and revenue attribution. Both `Transaction` and `Product` live in the `TactileModules.Analytics` namespace. ```csharp using TactileModules.Analytics; var product = new Product("com.mygame.coins_500", "500 Coins", 499, "USD", 4.99); sdk.Analytics().LogPurchase(new Transaction( purchaseSessionId: sessionId, product: product, transactionId: transactionId, transactionReceipt: receiptString, orderId: orderId, transactionSignature: signature, transactionTimestamp: DateTime.UtcNow, purchaseProvider: "apple" )); ``` ### `Register(IStatefulParameter statefulParameter)` Registers a stateful parameter that is automatically appended to every subsequent event. ```csharp sdk.Analytics().Register(new CurrentLevelParameter()); ``` ## Events ### `EventLogged` ```csharp event Action EventLogged ``` Fires after each event is queued. Useful for debugging or forwarding events to a secondary analytics system. ```csharp sdk.Analytics().EventLogged += (evt) => Debug.Log($"[Analytics] {evt.Name}"); ``` ## IStatefulParameter interface ```csharp public interface IStatefulParameter { string Key { get; } object Value { get; } } ``` Implement this interface to create a parameter that is evaluated and appended to every event at flush time. ## Transaction class `TactileModules.Analytics.Transaction` | Property | Type | Description | |----------|------|-------------| | `Product` | `Product` | Product details (see below) | | `PurchaseSessionId` | `string` | Unique identifier for the purchase session | | `TransactionId` | `string` | Platform-specific transaction identifier | | `TransactionReceipt` | `string` | Raw receipt or proof-of-purchase payload | | `TransactionSignature` | `string` | Optional signature for receipt verification | | `OrderId` | `string` | Order identifier associated with the transaction | | `TransactionTimestamp` | `DateTime` | Client-side timestamp of the purchase | | `PurchaseProvider` | `string` | Provider name: `"apple"`, `"google"`, or `"xsolla"` | | `EconomyChangeId` | `string` | Economy change identifier (optional, defaults to empty) | ## Product class `TactileModules.Analytics.Product` | Property | Type | Description | |----------|------|-------------| | `ID` | `string` | Store product identifier (e.g. `"com.game.iap.coins"`) | | `Title` | `string` | Human-readable product name | | `Price` | `int` | Price in minor currency units (e.g. $0.99 → `99`) | | `LocalCurrencyCode` | `string` | ISO 4217 currency code for the localized price | | `LocalPrice` | `double` | Localized price as reported by the store | --- ## PlayOpsConfigurations The remote configuration service. Access via `sdk.Configurations()`. ## Methods ### `Initialize()` Loads the bundled fallback configuration into memory. Must be called before any other method. ```csharp configurations.Initialize(); ``` ### `GetConfiguration()` Returns the current cached instance of `T`. Returns the bundled default until a successful `Sync()` or `Update()`. ```csharp var iapConfig = configurations.GetConfiguration(); ``` ### `Sync()` Coroutine. Downloads the latest configuration from the server and applies it to the local cache. ```csharp yield return configurations.Sync(); ``` Does not throw on network failure — the existing cached values remain active. ### `Download()` Coroutine. Downloads the latest configuration from the server without applying it. The downloaded data is passed to the `OnConfigurationDownloaded` event — subscribe to it and call `Update(hashtable)` when you are ready to apply. ```csharp configurations.OnConfigurationDownloaded += rawConfig => { // Apply when ready (e.g. between levels) configurations.Update(rawConfig); }; yield return configurations.Download(); ``` ### `Update(Hashtable rawConfiguration)` Applies raw configuration data to the local cache. Calling without an argument is a no-op — you must pass the hashtable received from `OnConfigurationDownloaded`. ### `Reset()` Clears the in-memory cache and removes the locally persisted configuration. ### `GetMetadataVersion()` Returns a `PlayOpsMetadataVersion` object with the version and environment of the active configuration. ```csharp var version = configurations.GetMetadataVersion(); Debug.Log($"Config version: {version.Version}"); ``` ## Events ### `OnConfigurationDownloaded` ```csharp event Action OnConfigurationDownloaded ``` Fires when a configuration is successfully downloaded from the server (before it is applied). ### `OnConfigurationUpdated` ```csharp event Action OnConfigurationUpdated ``` Fires after a configuration is applied — either from `Sync()` or `Update()`. --- ## Attribute Reference ## Analytics event attributes ### `[TactileAnalytics.EventAttribute(string name, bool validationRequired = true)]` **Target:** Class Required on every analytics event class. `name` is the event name as it appears in the data warehouse and dashboard. `validationRequired` defaults to `true` — set to `false` to skip server-side schema validation. ```csharp [TactileAnalytics.EventAttribute("levelCompleted")] public class LevelCompleteEvent { ... } ``` ### `[EventCategory(string category)]` **Target:** Class **Required:** No Groups events in the PlayOps dashboard UI. Helps organise large event schemas by feature area. ```csharp [EventCategory("Progression")] ``` Common categories: `"Progression"`, `"Monetisation"`, `"Social"`, `"Debug"`, `"Performance"`. ### `[EventDescription(string description)]` **Target:** Class **Required:** No, but strongly recommended Human-readable description of when this event fires. Shown in the schema viewer and used for auto-generated documentation. Write it as a sentence: "Fired when the player successfully finishes a level." ### `[EventChangeReason(string reason, string developer, string stakeholder)]` **Target:** Class **Required:** Yes, when modifying an existing event schema Tracks why an event schema changed, who made the change, and who requested it. All three parameters are required and cannot be empty. ```csharp [EventChangeReason("Add elapsed time property", "dev@studio.com", "pm@studio.com")] ``` ### `[UnregisteredDeviceEvent]` **Target:** Class **Required:** No Marks an event as being sent for unregistered devices — devices that have not yet completed the full registration flow with the backend. ### `[EventPropertyDescription(string description)]` **Target:** Property **Required:** Yes (to include the property in the schema) Documents a property in the generated schema. Properties without this attribute are ignored by the schema generator. Write a description of what the value represents. ```csharp [EventPropertyDescription("Stars earned on completion (0–3).")] private TactileAnalytics.RequiredParam starsEarned { get; set; } ``` ### `[PersonalData]` **Target:** Property **Required:** No Marks a property as containing personal or sensitive data. Used for data governance and compliance. ```csharp [PersonalData] [EventPropertyDescription("Player email if provided.")] private TactileAnalytics.OptionalParam email { get; set; } ``` ## Analytics parameter types ### RequiredParam<T> Wrapper struct for required event properties. The schema generator marks these as required. ```csharp private TactileAnalytics.RequiredParam levelName { get; set; } ``` ### OptionalParam<T> Wrapper struct for optional event properties. The schema generator marks these as optional. ```csharp private TactileAnalytics.OptionalParam localPrice { get; set; } ``` Both types have implicit conversion operators, so you can assign values directly: ```csharp this.levelName = "world1_level3"; this.localPrice = 4.99; ``` ### Supported types for `T` | C# Type | JSON Prefix | Max Parameters | |---------|------------|----------------| | `string` | `s_param` | 100 | | `int` | `i_param` | 100 | | `double` | `f_param` | 50 | | `bool` | `b_param` | 50 | | `DateTime` | `ts_param` | 50 | `float` is not supported — use `double` instead. ## Configuration attributes ### `[Tactile.ConfigProvider(string key)]` **Target:** Class Maps a C# class to a top-level key in the server-side configuration JSON. `key` must exactly match the key as defined in the PlayOps dashboard configuration editor. ```csharp [Tactile.ConfigProvider("IAPConfig")] public class IAPConfig { ... } ``` ### `[JsonSerializable(string name)]` **Target:** Property Maps a property to a JSON field name for deserialization. ```csharp [JsonSerializable("FeaturedProductId")] public string FeaturedProductId { get; set; } ``` ### `[JsonSerializable(string name, Type elementType)]` **Target:** Property For `List` properties. Specifies the element type for deserialization. ```csharp [JsonSerializable("InAppProducts", typeof(InAppProductInfo))] public List InAppProducts { get; set; } ```