Skip to content

Data, unions, and maps ​

Structured types live in the same source file as the component, before the class. They compile with the class. Pure module functions have no component owner and cannot touch scene, input, randomness, or mutable state.

Records, enums, options, assets ​

csharp
enum Element { Fire, Ice }
record Spell { float Damage; Element Kind; Asset<sound> Sound; }
module CombatMath {
    public pure float Mitigate(float damage, float armor) {
        return Math.Max(0, damage - armor);
    }
}
class SpellBook : Component {
    [Property] Spell Selected;
    [Property] List<Spell> Learned;
    Option<int> bonus = new Option<int>();

    public float Preview() {
        return CombatMath.Mitigate(Selected.Damage, 2);
    }
}

Records copy immutable values and compare structurally. A suspended task can capture a record and keep that revision while later calls see updated tuning.

Records may contain scalars, vectors, enums, typed assets, other records, options, tagged unions, and immutable string-key maps. Mutable lists, entities, tasks, and component handles cannot live in a record. Schemas are acyclic and bounded in depth and expanded size.

List<Record> and List<Option<T>> are supported. Lists themselves keep reference semantics. var is local inference only, never a persisted field.

Use new Option<T>() for absence and new Option<T>(value) for presence. Guard with if (value.HasValue) before .Value. An early return on absence also narrows; reassigning a guarded local invalidates that narrowing. See Option.

Asset<sound>, Asset<image>, Asset<blueprint>, Asset<animation>, and Asset<particle> keep stable ids and constrain pickers. Older [AssetKind("sound")] Asset promotes the same type. Untyped Asset is a compatibility escape; the compiler rejects proven wrong-kind calls. A typed handle does not promise the bytes exist or have loaded.

Property schemas travel with serialized records so nested assets follow save, prefab, undo, export, and remap. Record field or enum renames that would alter stored schemas are refused by ordinary Rename — introduce an explicit version and migration for saved data.

The Inspector expands record properties into named fields (enum menus, typed asset pickers, nested records). Optional values have a Has value switch. Editing a member replaces that record and preserves other saved members. Unknown saved enum values stay visible until you choose a supported case.

Game.GetData<T>(name) / Game.SetData<T>(name, value) commit an immutable record through a declared text GameState slot. The engine owns JSON encoding and validation. Include a Version field and handle unsupported revisions explicitly. One SetData commits the whole record before listeners run; stored text is bounded to one million characters. The saved chest example proves one key/loot/unlock transaction across scene changes.

Tagged unions and exhaustive match ​

csharp
union Result {
    [CaseId("released")] Released(int Damage, Asset<sound> Sound);
    Rejected(string Reason);
}

module Outcomes {
    public pure int Damage(Result result) {
        return match (result) {
            Result.Released(damage, sound) => damage,
            Result.Rejected(reason) => 0
        };
    }
}
  • Construct cases as Result.Released(5, sound) or Result.Rejected("busy").
  • Payload members are not fields on the union value (result.Damage is an error). Bind them in match.
  • match must be exhaustive. Duplicate cases, wrong arity, and mixed branch types are compile errors.
  • Bindings from a case are scoped to that arm.
  • match also covers enums and Option<T> (Option.Some(value), Option.None).
  • [CaseId("released")] is the saved identity. Rename of the case name keeps stored payloads when the CaseId stays.

Unions are immutable values. They can sit on [Property] fields, inside records, and as map values.

Immutable maps ​

Map<string, T> is an immutable string-keyed map, at most 1,024 keys. Values must themselves be immutable (records, unions, scalars, options, nested maps of immutable values — not List<T>).

csharp
[Property] Map<string, Result> History = new Map<string, Result>();

void Write() {
    History = History.With("wave-1", Result.Rejected("ten"));
}
MemberMeaning
With(key, value)New map with the key set; replacing a key keeps its position
Without(key)New map without the key
Get(key)Option<T> — Some or None
Has(key)Whether the key exists
CountRead-only size
KeysList<string> snapshot — mutating it does not change the map

Keys are ordinary strings, including unusual names such as "__proto__". There is no Map<int, T>. Assignment shares the immutable value; With returns a different map. Nested maps share the ordinary data-expansion budget (deep shared nests can fail with a budget error rather than amplifying equality or serialization). See Map.

Lists ​

List<T> is a mutable collection of at most 1,024 items. Assignment shares identity.

csharp
List<Entity> targets = new List<Entity>();

void OnSensorEnter(Entity other) {
    if (!targets.Contains(other)) targets.Add(other);
}

Element types: int, float, bool, string, Vector2, Entity, an enabled component class, typed assets, records, enums, or options. Lists are invariant (List<int> is not List<float>). Nested lists are not supported.

Initialize with new List<T>() before use. A declaration without an initializer starts as null. Out-of-range indices, wrong item types, and null list access are script errors. Searching and shifting count toward the shared execution budget.

foreach (T item in items) evaluates the collection once. The iteration variable is read-only. Changing the collection through any alias is detected when iteration advances; remove entries with a backward indexed loop:

csharp
for (int i = targets.Count - 1; i >= 0; i--) {
    if (targets[i] == null) targets.RemoveAt(i);
}

Vector2 items copy on read and write; points[index].X = 5 writes back. Entity and component slots use the same scene-bound references as fields; deleted targets read as null until you remove the slot.

[Property] lists and supported public action parameters persist through stores, prefabs, and packages. Lists of component handles stay runtime-only. Compatible reload preserves runtime aliases; a new Play session restores authored values. See List.

The Damage Zone tutorial combines sensors, native Health, timed damage, and safe cleanup.

Interfaces ​

csharp
interface ITarget {
    pure bool IsAlive();
    async Task<int> Prepare();
}
class Enemy : Component, ITarget {
    [Pure] public bool IsAlive() { return true; }
    public async Task<int> Prepare() {
        await Time.NextFrame();
        return 5;
    }
}

Implementations must match public names, parameter types, result types, and effects. Parameter names may differ. GetComponent<ITarget>() returns the first enabled implementation in attachment order, or null. Scene.FindWith<ITarget>() returns each matching Entity once.

Interface handles are runtime fields or locals — not [Property], not record members, not Flow inputs. Limits: 1–128 method signatures per interface; at most 16 interfaces per component. No fields, events, lifecycle hooks, or interface inheritance. Rename follows the declaration, implementations, and calls while preserving concrete methods’ Flow identities.

Focused generics ​

Immutable records and pure module functions can declare one immutable type parameter. Supply the argument explicitly. Component and interface declarations stay nongeneric.

csharp
record Pair<T> { T First; T Second; }
module Choices {
    public pure T Pick<T>(bool first, T a, T b) { return first ? a : b; }
}

Mutable lists, entities, tasks, and component handles cannot be generic arguments. Concrete records keep their full nominal type (Pair<int>) in saved schemas and definition assets. Specialization is bounded (16 nesting levels, 128 specializations per component, 65,536 expanded syntax nodes) plus ordinary immutable-data bounds.

Units and coordinate spaces ​

Use Seconds, Milliseconds, Degrees, Radians, Pixels, and PixelsPerSecond when mixing numeric meanings would be a mistake. Same-unit addition/comparison and scalar scaling preserve the unit; equal-unit division produces a ratio. PixelsPerSecond * Seconds produces Pixels. Angles convert through Units.Radians / Units.Degrees. No unit implicitly becomes a float. .Value extracts the number. See Units and world.

Definition assets (.opdef) ​

Script Studio → Definitions creates an asset from a concrete immutable record type. Source holds type, schema, value, and a content-derived revision. Inspector DataAsset<T> pickers filter by that record type.

csharp
record Weapon { float Damage; Seconds Windup; Asset<sound> Sound; }
class WeaponUser : Component {
    [Property] DataAsset<Weapon> Definition;
    Weapon Captured;
    void Start() { Captured = Definitions.Get<Weapon>(Definition); }
    public async Task<float> ReadDamage() {
        var weapon = await Definitions.Load<Weapon>(Definition);
        return weapon.Damage;
    }
}

Definitions.Get<T> reads an already prepared revision and fails when unavailable. Both player roots preload referenced scene definition assets before Start. Definitions.Load<T> returns an owned Task<T>; loading completes on a gameplay frame. Cancellation or owner retirement prevents a late continuation. Both calls need the exact DataAsset<T> and current schema. Neither is pure; starting a load is also prohibited in finally.

Captured values stay immutable when a new revision is saved. Invalid changed bytes do not replace the last valid cache entry. Schema changes need the panel’s explicit migration review. Only the asset id is saved on the component. Nested referenced assets follow ordinary package collection and remap. Values never contain mutable lists, entities, tasks, or component handles.

Walkthrough: typed gameplay and balance data. API: Definitions.