Skip to content

Typed gameplay: project state and balance data ​

Use Game symbols for shared project state, enums for meaningful choices, records for immutable values, and .opdef assets for reusable balance data. Use the existing HealthChange result when a reward or effect depends on what a health command actually did.

These features are already implemented. This workflow uses the existing APIs; it does not require a new state service or balance-file format.

Find the tools ​

  • Variables declares project GameState fields and their lifetimes.
  • Script Studio → Reference lists the available APIs. Type Game. in a script for project-specific completions; hover shows types and Go to Definition opens the owning project tool. Source containing state, records, or health calls gets related reading suggestions in Tutorials.
  • Script Studio → Tutorials includes the four full examples below. To use one, create a Behavior Component, select all of its starter text, and choose Insert example, then Save & Apply. The insertion replaces your selection; selecting all avoids nesting a complete class inside the starter class.
  • Script Studio → Definitions creates and edits .opdef files using record schemas from successfully compiled project scripts.

Each example is a separate project script with one component class. Shared type declarations are visible across the project library; declare each enum and record once, even if several components use it. The four examples can compile together, but their gameplay wiring is explicit: adding scripts does not attach components, create state variables, connect Flow, or create definition assets automatically.

Choose the state owner ​

NeedAuthoring choiceExample
Cross-object or cross-scene counterVariables, Number, SessionGame.WaveIndex
Scene flag shared with Flow/UIVariables, Boolean, SceneGame.WaveActive
Persistent currency or unlockVariables, Saved lifetimeGame.Coins or a Boolean unlock
A controller's phaseScript enum fieldRunPhase.ChoosingUpgrade
Per-player changing statsComponent fieldsDamage, ShotsPerSecond
Small inline authored schedule[Property] List<WaveDefinition> on a componentDesigner-editable wave entries
Shared wave/upgrade tuningRecord plus [Property] DataAsset<T>OpeningWave.opdef, HeavyHit.opdef

Session fields reset when Play starts and carry across scene changes. Scene fields reset each time a scene starts. Saved fields survive separate plays. Choose Saved only for data that should persist; component runtime fields belong to their instance and do not become saved project state merely because they are public.

What typed Game symbols support today ​

Variables typeOpalScript type
Numberfloat (including counters holding whole numbers)
Booleanbool
Textstring
ObjectEntity

The Variables panel's Object is an entity reference, not an arbitrary JSON object or a record field. Its heterogeneous List does not expose a typed Game member. Enums and records belong in typed script fields or definition assets in this authoring workflow. The existing Game.GetData<T> / SetData<T> API serves schema-backed record GameState, but the current Variables panel does not author those schemas. Do not select Object and expect it to create that path.

Declare names before compiling sources that use them. New fields named WaveIndex, WaveActive, and Coins normally expose the matching Game members. Existing aliases may differ: use completion to find the binding. Script aliases retain stable variable identities across display-name edits. Use Rename Symbol to preview and apply a script alias change; creating another field with the same label after deleting the original does not restore the original binding.

Game.GetNumber("Coins") and the other string accessors remain available. Prefer the typed symbol when the project owns the declaration: missing names and wrong types then produce compile diagnostics. A Number reads as float; use an explicit conversion such as Math.Round(Game.WaveIndex) when an API needs int.

Shared definitions and live state ​

An .opdef is the ScriptableObject-like choice for reusable, typed tuning: a record declares its schema and a named project asset stores its authored values. It has no component lifecycle or mutable gameplay instance. Multiple components can select the same DataAsset<T> handle, then capture immutable values through Definitions.Load<T> or a preloaded Definitions.Get<T>.

Keep current HP, currency, timers and upgrade progress in component fields or GameState. Keep base damage, upgrade prices and wave parameters in definitions. A runtime calculation such as a repair price can read both through a public [Computed] scalar method; it does not need a duplicate Inspector field or a per-frame copy into a string-keyed variable. See typed HUD bindings for a complete definition → component → computed value → UI workflow.

Own phase transitions in one controller ​

In Variables, create:

NameTypeLifetimeDefault
WaveIndexNumberSession0
WaveActiveBooleanScenefalse

Save this as a project Behavior Component and attach Run Director to one scene controller. Call Begin Wave with the actual participating enemy count; wire Record Defeat once per enemy death. Call Finish Upgrade after a successful choice and End Run on victory or defeat. The enum prevents another wave starting while fighting, choosing an upgrade, or finished.

csharp
enum RunPhase { Preparing, Fighting, ChoosingUpgrade, Finished }

class RunDirector : Component {
    public RunPhase Phase = RunPhase.Preparing;
    public int EnemiesRemaining;

    public void BeginWave(int enemyCount) {
        if (Phase != RunPhase.Preparing || enemyCount <= 0) return;
        EnemiesRemaining = enemyCount;
        Game.WaveIndex += 1;
        Game.WaveActive = true;
        Phase = RunPhase.Fighting;
    }

    public void RecordDefeat() {
        if (Phase != RunPhase.Fighting) return;
        EnemiesRemaining--;
        if (EnemiesRemaining == 0) {
            Game.WaveActive = false;
            Phase = RunPhase.ChoosingUpgrade;
        }
    }

    public void FinishUpgrade() {
        if (Phase == RunPhase.ChoosingUpgrade) Phase = RunPhase.Preparing;
    }

    public void EndRun() {
        Game.WaveActive = false;
        EnemiesRemaining = 0;
        Phase = RunPhase.Finished;
    }
}

This controller tracks a known wave; it does not spawn enemies. Spawner requests are not confirmed living-enemy counts, so do not connect every request to the death counter without accounting for spawn limits/failures. Use one death accounting path per enemy. The controller rejects calls outside Fighting, but cannot deduplicate repeated death reports for one enemy within a wave.

A new controller in another scene starts in Preparing. If a run should preserve its exact phase across scene changes, explicitly restore that state as part of scene setup. The shared WaveIndex counter alone does not restore the component.

Author a wave definition ​

  1. Save & Apply the following Behavior Component. The record declares the shape; its fields appear in the Definitions editor. Runtime checks below enforce the gameplay limits in addition to the schema's numeric types.
  2. Open Script Studio → Definitions. Choose WaveDefinition, click New definition, and name it OpeningWave. Set Count = 6 and Interval = 0.5, then Save definition revision.
  3. The editor creates OpeningWave.opdef in the project's Definitions asset folder. Attach Balanced Wave to a controller and choose this asset in its Wave property in the Inspector.
  4. Add Spawner to the same controller, choose a prefab, disable Start on play and Spawn immediately, and set Max Spawns = 0 for unlimited requests. Call Spawn Wave from a Flow action. Cancel Wave cancels this component's pending tasks.

For pausing a wave's waits together with Motion and sprite/skeleton clips, see time groups and pausing. Put a pause-menu controller in the ui group so it can resume paused gameplay.

csharp
record WaveDefinition { int Count; float Interval; }

class BalancedWave : Component {
    [Property] DataAsset<WaveDefinition> Wave;
    bool spawning;

    public async void SpawnWave() {
        if (spawning) return;
        spawning = true;
        try {
            WaveDefinition config = await Definitions.Load<WaveDefinition>(Wave);
            if (config.Count < 1 || config.Count > 128 || config.Interval < 0.05) return;
            Spawner spawner = GetComponent<Spawner>();
            if (spawner == null) return;
            spawner.StopSpawning();
            for (int i = 0; i < config.Count; i++) {
                if (spawner == null) return;
                spawner.SpawnNow();
                if (i + 1 < config.Count) await Time.Wait(config.Interval);
            }
        } finally {
            spawning = false;
        }
    }

    public void CancelWave() { Time.CancelAll(); }
}

await Definitions.Load<T>(asset) handles assets that have not loaded yet. Definitions.Get<T>(asset) is synchronous and requires preloading. Both return immutable typed values. A load failure reports a task error and executes the finally cleanup, allowing a later attempt. The guard stops overlapping sequences on this component; it is not a global lock across several controllers.

The local config captures one revision. Editing OpeningWave during a wave does not change that in-flight wave. Saving in Definitions refreshes the asset; the next load captures the new values. Create another WaveDefinition asset to reuse the same behavior with different tuning. Referenced definitions and their nested asset references participate in game export; keeping the selected prefab on Spawner also keeps that reference discoverable.

Records are immutable. To change an inline WaveDefinition value, construct a new value, for example new WaveDefinition(8, 0.25), and assign the whole record. An inline [Property] List<WaveDefinition> belongs on a component; a mutable List<T> cannot be stored inside an immutable record.

To edit later, double-click the .opdef in the asset library or choose it in Script Studio → Definitions → Saved definition → Open definition. The asset library's Open definition action follows the same route. Both open the typed fields; opening an asset does not place an object in the scene. The saved-definition list shows each asset's record type and includes current-project and shared assets. Opening a shared asset saves a project copy instead of replacing the shared original.

The panel shows loading, saving and unsaved states. Save or explicitly Discard changes before creating or opening another definition; discarding an edited asset restores its opened/saved values and name. An invalid name or schema value disables Save and explains the problem. A failed write keeps the editable draft for retry. If saving succeeds but the preview cannot refresh, the message says Saved and reports the refresh failure separately; the committed asset keeps its identity. Refresh types & assets retries a failed list load and discovers newly declared types or imported assets without replacing the current draft.

Save definitions before switching scripts, projects or workspaces; drafts are local to the mounted Definitions panel. The asset-opening guard protects against replacing a draft through another asset open, not against every editor navigation.

After changing a record schema, Save & Apply the script, reopen the .opdef, and review the proposed field additions, removals, or resets. Accept the schema changes before saving the next revision. Do not hand-edit revision metadata or overwrite an asset with a stale draft. The Definitions editor handles those checks.

Author upgrade prices and effects ​

Add Coins in Variables as Number, Session, 0. For a quick Play check, temporarily set its default to 10, then restore it after testing. Choose Saved only if currency should persist between separate plays.

Save & Apply this Behavior Component. In Definitions, create these two UpgradeDefinition assets:

NameKindCostAmount
HeavyHitDamage104
RapidFireFireRate150.5

Attach Upgrade Shop to the player and select HeavyHit.opdef in Upgrade. A Flow button can call Purchase Upgrade. The player's weapon behavior should read the public Damage and ShotsPerSecond fields from this player's UpgradeShop component. To offer another item, select the corresponding definition handle on that same stats owner; separate shops have separate component stats.

csharp
enum UpgradeKind { Damage, FireRate }
record UpgradeDefinition { UpgradeKind Kind; int Cost; float Amount; }

class UpgradeShop : Component {
    [Property] DataAsset<UpgradeDefinition> Upgrade;
    [Property] public float Damage = 10;
    [Property] public float ShotsPerSecond = 1;
    bool purchasing;

    public async void PurchaseUpgrade() {
        if (purchasing) return;
        purchasing = true;
        try {
            UpgradeDefinition config = await Definitions.Load<UpgradeDefinition>(Upgrade);
            if (config.Cost < 0 || config.Amount <= 0) return;
            // Check the current balance after loading; spend without another await.
            if (Game.Coins < config.Cost) return;
            Game.Coins -= config.Cost;
            if (config.Kind == UpgradeKind.Damage) Damage += config.Amount;
            else ShotsPerSecond += config.Amount;
        } finally {
            purchasing = false;
        }
    }
}

Loading can yield, so the example checks the current balance after the await. The spend and stat update have no intervening await. Invalid tuning, failed loads, and insufficient currency spend nothing. From 10 Coins, HeavyHit leaves 0 Coins and Damage 14; another purchase with 0 Coins changes nothing. RapidFire changes ShotsPerSecond instead. The assets are immutable prices/effects; the component owns mutable player stats.

This template supports repeatable purchases. Wire your run-phase gate to the purchase UI, and add an owned unlock/tier state if an upgrade should be one-time. Persisting currency does not automatically persist these component stats.

Use explicit HealthChange results ​

With Coins declared as above, attach Rewarding Hit to a hazard. Give the target Health, both objects Collider 2D, and at least one a moving Rigid Body 2D. Give the hazard its own Health if you also want to use the Heal Self action.

csharp
class RewardingHit : Component {
    [Property] float Damage = 20;
    [Property] int KillReward = 5;
    public string LastReason;
    public float LastAmount;

    void OnCollisionStart(Entity other) {
        Health health = other.GetComponent<Health>();
        if (health == null) return;
        HealthChange hit = health.TakeDamageResult(Damage);
        LastReason = hit.Reason;
        LastAmount = hit.Amount;
        if (hit.Applied && hit.Killed) Game.Coins += KillReward;
    }

    public void HealSelf(float amount) {
        Health health = GetComponent<Health>();
        if (health == null) return;
        HealthChange healing = health.HealResult(amount);
        LastReason = healing.Reason;
        LastAmount = healing.Amount;
    }
}

With Damage 20 and a target on 10 HP, the result's Amount is 10, Applied and Killed are true, and Coins increases by KillReward. Invulnerable or already-dead targets refuse damage; full-health targets refuse healing. A refusal has Applied false and a Reason. Missing Health is guarded before the call.

PreviousHp, RemainingHp, Amount, and Killed describe the committed command. A listener can heal, kill, or remove the target during notifications; that does not rewrite the result. Read health.Hp when you need the current HP, and the returned result when you need the outcome of this particular hit. OperationId, SourceId, and TargetId provide provenance.

Award the same kill reward from one path only: either this result or a Died listener. Do not infer a successful hit from a later zero-HP read. Existing TakeDamage and Heal remain available when their result is unnecessary. See Committed Health commands for event ordering.

Troubleshooting ​

SymptomNext step
Unknown Game.CoinsDeclare Coins in Variables; use completion to check its actual alias.
Expected int, found floatNumber Game symbols read as float; use a deliberate conversion for an int API.
Duplicate enum or record declarationKeep one declaration in the project library; other scripts use its type name.
Definitions has no record typesSave & Apply the record script and fix all project compile errors; reopen Definitions.
The .opdef is absent from the property pickerCheck that the property is DataAsset<ThatRecord> and the saved asset has the same record type.
Get reports the asset has not loadedUse await Load, or explicitly arrange preloading before Get.
An edit does not affect an active waveIt holds a captured immutable revision; start a new wave to load the new tuning.
A wave ends too earlyCheck that each enemy reports defeat once and the count reflects actual participants.
Currency persists but upgrades resetSaved GameState and component stats have separate lifetimes; author an explicit restore path.

The full examples are checked together and independently against the actual compiler and project-symbol catalog in src/game/scripts/editor/typed-gameplay-tutorials.test.js. That test also checks the .opdef schemas and keeps these code examples aligned with the editor's insertable templates. Run it with node --test src/game/scripts/editor/typed-gameplay-tutorials.test.js.