ECSGAM200 Scripting
Engine documentation · October 2026

Scripting the GAM200 engine

Components are JSON data. Systems are C# scripts that run over them every frame. This page covers how to write both, every function scripts can call, and the engine's built-in components. Press / to search.

Start here

How scripting works

You write components as JSON files and systems as C# scripts. The editor turns the JSON into C# types, wires your systems into the engine and recompiles every time you save. You never write or see that plumbing.

You writeWhereWhat it is
ComponentComponents/Enemy.jsonData only: a list of named, typed fields. Becomes a C# struct called Enemy.
SystemScripts/EnemySystem.csA static class with [ECSSystem(id)]. Its Update runs every frame over every entity that has the component.
Editor toolEditor/MyTool.csOptional windows, menu items and inspectors for the editor. Never shipped with the game.

Project layout

Everything lives in your project folder (the one with the .astge file), never in the engine folder.

MyGame/
  MyGame.astge          project file: open this in the editor
  Scenes/               *.scene.json, made in the editor
  Prototypes/           *.proto.json, reusable entities ("prefabs")
  Components/           *.json, your components
  Scripts/              *.cs, your systems (shipped with the game)
  Editor/               *.cs, your editor tools (editor only)
  Animations/           *.anim.json animation clips
  Assets/ ...           art and audio, any folder names you like
  Resources/            files you only load by name from code (always exported)
  StreamingAssets/      data files read with Assets.ReadText (always exported)
  ProjectSettings/      Build.json (Build window settings)
  Library/              editor cache and editor save data   (do not commit)
  Builds/               exported games                      (do not commit)

The frame, in order

Knowing the order explains most "why didn't this happen yet" questions.

  1. Input and events. SDL events are polled, then queued events are delivered (scene loads, spawns, your events).
  2. FixedUpdate. 0 to 5 steps at 60 Hz. Physics runs here.
  3. Update. Systems in phase order PreUpdate → Update → PostUpdate, lower Order first. Commands are flushed between phases.
  4. Animation, then LateUpdate (camera, UI, hierarchy).
  5. Frame end. Commands flushed, events delivered again, timers fire, Session.FrameEnd runs, debug lines and UI are sent.
  6. Audio and render. Queued sounds start, then the frame is drawn.
Start here

Your first component and system

This walkthrough makes a sprite spin, with a speed you can change in the Inspector. It touches every part of the workflow.

1. Define the data

Create Components/Spinner.json (or Scripts › New Component). Pick an id of 1000 or more that nobody else uses.

Components/Spinner.json
{
  "name": "Spinner",
  "id": 1200,
  "fields": [
    { "name": "Speed",   "type": "float", "default": 90, "tooltip": "Degrees per second" },
    { "name": "Enabled", "type": "bool",  "default": true }
  ]
}

2. Write the behaviour

Create Scripts/SpinnerSystem.cs. The SpinnerCollection parameter tells the engine which entities this system runs over.

Scripts/SpinnerSystem.cs
using Engine.Core;
using GeneratedECS;
using static GeneratedECS.GeneratedECSRegistry;

namespace Game;

[ECSSystem(6200)]                                   // unique system id
public static class SpinnerSystem {
    public static void Update(float dt, SpinnerCollection spinners) {
        for (int i = 0; i < spinners.Length; i++) {
            var s = spinners[i];
            if (!s.Enabled) continue;
            if (Transform.TryGet(s.EntityId, out var t))
                t.Rotation += s.Speed * dt;          // writes straight into the entity
        }
    }
}

3. Use it

  1. Save both files. The editor compiles automatically; errors appear in the Console with file and line.
  2. Select an entity with a Sprite, click Add Component and pick Spinner.
  3. Press Play. Change Speed in the Inspector while it runs.
  4. Press Stop. Play-mode changes are discarded; save the scene (Ctrl+S) for changes made while stopped.
Start here

Everyday workflow

ActionHow
New systemScripts › New Script, or create a .cs in Scripts/
New componentScripts › New Component, or create a .json in Components/
New editor windowTools › New Editor Window Script
CompileAutomatic on save. Status in the Scripts menu.
RunPlay / Pause / Stop. Undo/redo (Ctrl+Z / Ctrl+Y) works outside Play mode.
Find an errorDouble-click a Console line to open the file at that line. Error Pause pauses Play on the first exception.
Open the code in your IDEThe editor writes a .csproj for the project so IntelliSense knows the generated types.

IntelliSense is missing my component

Generated types such as EnemyCollection come from the component JSON. If your IDE doesn't know them yet, save and let the editor compile once, then reload the project in the IDE. After changing the generator itself, restart Visual Studio, which caches it.

GameScripts.csproj and git

The editor writes GameScripts.csproj into the project folder whenever it opens the project. It is only for your IDE (the editor compiles scripts itself) and it is committed with the project. The engine build folder is written relative to the project, so everyone with the same folder layout gets the same file, whichever drive or folder the repository is in:

GameScripts.csproj (excerpt)
<EcsEngineExeDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\local_desktop_build\Debug'))</EcsEngineExeDir>
<EcsEngineSourceDir>$(EcsEngineExeDir)\ScriptEngine</EcsEngineSourceDir>
<EcsToolsDir>$(EcsEngineExeDir)\Tools</EcsToolsDir>

It still changes when you open the project with a Release editor (Release instead of Debug in that path) or keep the project somewhere else relative to the build folder. Either version works for anyone who has that build folder, so the team commits the Debug one and discards the other change. Don’t edit the file by hand: the editor rewrites it.

Components

Writing a component

A component is plain data. One JSON file per component, anywhere under a Components/ folder.

Components/Enemy.json
{
  "name": "Enemy",
  "id": 1100,
  "trackChanges": false,
  "fields": [
    { "name": "Health", "type": "float", "default": 100, "range": [0, 100], "tooltip": "Hit points" },
    { "name": "Alive",  "type": "bool",  "default": true },
    { "name": "Home",   "type": "Vec2",  "default": "0, 0" },
    { "name": "Tint",   "type": "Color", "default": "#FF8800" },
    { "name": "State",  "type": "enum",  "values": ["Idle", "Chase", "Attack"], "default": "Idle" },
    { "name": "Target", "type": "Entity", "header": "AI" },
    { "name": "Icon",   "type": "Texture" },
    { "name": "Hit",    "type": "Audio" },
    { "name": "Name",   "type": "string" },
    { "name": "Loot",   "type": "List<string>" }
  ]
}

Top-level keys

KeyRequiredMeaning
nameyesComponent name and C# type name. Must be a valid identifier.
idyesUnique positive number. Game components use 1000 and up.
fieldsyesArray of fields (below).
trackChangesnoEnables Changed(i) in systems. See change tracking.

Field types

JSON typeC# propertyInspectorSaved in scenes as
float int uintfloat / int / uintDrag field; slider with rangenumber
boolboolCheckboxtrue / false
ulongulongNumber boxnumber
Vec2Vec2Two fields[x, y]; default "1, 2"
ColorColorColour picker[r, g, b, a] 0..1; default "#RRGGBB(AA)"
enum + valuesgenerated enum EnemyStateCombo box"Chase"
Entityulong handle, 0 = noneDrag from Hierarchytarget's scene id; survives save/load and duplicate
Texture Animation Audio Font Scene Prototype Assetstring path + <Field>Id intAsset picker; drag from Project"Assets/Images/hero.png"
stringstringText boxtext
List<string>List<string>Editable listarray of text

Aliases also accepted: Sprite/Image (Texture), Anim/AnimationClip, Sound/AudioClip.

Per-field keys

KeyEffect
defaultStarting value. Numbers, true, "x, y", "#hex", an enum value name, or an asset path.
tooltipHover text in the Inspector, and the XML doc comment in C#.
headerBold section title above the field.
hiddenNot shown in the Inspector (still saved).
readOnlyShown but not editable (values the engine writes).
range[min, max]: number field becomes a slider.
enumNameName of the generated enum (default <Component><Field>). Two components can share one enum this way.

Mistakes

Bad JSON, an unknown type, a duplicate field, or two components with the same id or name are compile errors that name the file (GEN001–GEN004). The bad component is left out; everything else still compiles.

Components

Using components from C#

Every component JSON produces a struct in GeneratedECS.GeneratedECSRegistry, which the using static at the top of every script brings into scope. The struct is a view: it holds a pointer to the entity's data, so setting a property writes straight into the engine. Copying it into a local variable does not copy the data.

// from anywhere: systems, event handlers, timers, UI callbacks
if (Enemy.TryGet(entity, out var en)) en.Health -= 10;   // safe: checks first
var t = Transform.Get(entity);                             // throws if missing
bool armed = Weapon.Has(entity);
foreach (var e in Enemy.All()) Debug.Log(e.Health);        // every Enemy in the world

// asset fields: path string, or the fast int id
var sprite = Sprite.Get(entity);
sprite.Texture = "Assets/Images/Boss.png";
if (sprite.TextureId == other.TextureId) { /* same image */ }

// enum fields use the generated enum
if (en.State == EnemyState.Chase) { /* ... */ }

Adding and removing

CallWhen to use
Enemy.Create(e)Adds now with JSON defaults and returns the view. Only outside system loops (OnStart, event handlers, timers).
Enemy.AddDeferred(e)Inside a system loop. The component exists after the next flush.
cmd.Add(e, Enemy.TypeId, in data)Inside a loop, with your own starting values. Build data from Enemy_Defaults().
Enemy.Remove(e)Anywhere; deferred automatically while systems run.
public static void Update(EnemyCollection enemies, Commands cmd) {
    for (int i = 0; i < enemies.Length; i++) {
        var e = enemies[i];
        if (e.Health <= 0 && !Burning.Has(e.EntityId)) {
            var b = Burning_Defaults();           // raw struct with the JSON defaults
            b.Duration = 3f;
            cmd.Add(e.EntityId, Burning.TypeId, in b);
        }
    }
}
Components

Built-in components

The engine's own components (ids 100–127) work exactly like yours. Their fields are listed in the component reference.

ComponentPurpose
TransformPosition (world units), Rotation (degrees, counter-clockwise), Scale (1 = 100 units). World space.
SpriteTexture, tint, flip, sprite-sheet Frame (-1 = whole image), draw Order (higher on top).
AnimatorPlays an .anim.json clip.
Camera2DThe game camera.
Collider Rigidbody TagPhysics and tags.
Canvas UIRect UIImage UIText UIButton UIInputField UIToggle UISliderUI.
ParentAdded by the editor when you nest entities. Don't add it by hand; use Hierarchy.SetParent.
Systems

Writing a system

A system is a static class marked [ECSSystem(id)] with any of six lifecycle methods. Write only the ones you need.

Scripts/EnemySystem.cs
using Engine.Core;
using GeneratedECS;
using static GeneratedECS.GeneratedECSRegistry;

namespace Game;

[ECSSystem(6000)]
public static class EnemySystem {
    public static void OnStart() {
        Physics.OnTriggerEnter(Scenes.Find("Goal"), c => Scenes.Load("Level2"));
    }

    public static void Update(float dt, EnemyCollection enemies, Commands cmd) {
        for (int i = 0; i < enemies.Length; i++) {
            var e = enemies[i];
            if (e.Health <= 0) { cmd.Destroy(e.EntityId); continue; }
            if (e.State == EnemyState.Chase && World.IsAlive(e.Target)) { /* move towards it */ }
        }
    }
}

Lifecycle methods

MethodRuns
OnCreateOnce, when the system is registered (also after each script reload).
OnStartOnce, when Play starts. Subscribe to events and callbacks here.
UpdateEvery frame, in the system's phase.
OnFixedUpdate0 to 5 times per frame at a fixed 60 Hz. Don't read KeyPressed here: it can be missed or seen twice.
OnLateUpdateAfter all Updates and animation.
OnDestroyWhen Play ends.

Parameters

Every method takes 0–3 parameters, matched by type in any order:

  • float: delta time (same as Time.DeltaTime).
  • One XCollection or one Query<A, B>: the entities this system runs over. Use the same one in every method of the system.
  • Commands: deferred structural changes.

A system without a collection still runs once per frame. Use it for global logic (input, spawning waves, HUD).

Systems

Phases and order

[ECSSystem(6010, Phase = SystemPhase.PreUpdate, Order = 10)]
public static class ReadInputSystem { /* ... */ }

Phases run PreUpdate → Update (default) → PostUpdate. Inside a phase, lower Order runs first (default 0, negatives allowed).

Engine systemIdPhaseOrderDoes
HierarchySystem900PostUpdate10000Children follow parents
PhysicsSystem910PostUpdate5000Bodies, collisions (in OnFixedUpdate)
CameraSystem920PostUpdate20000Follow, bounds, shake (OnLateUpdate)
UISystem930PostUpdate30000Layout, input, drawing (OnLateUpdate)

So gameplay in Update sees last frame's physics result, and moves you make in Update are applied to children and the camera the same frame.

Systems

Several components: Query

When a system needs entities that have two or more components, take a Query instead of a collection.

[ECSSystem(6020)]
[Without(typeof(Frozen))]                         // optional: skip entities that have Frozen
public static class MoveSystem {
    public static void Update(float dt, Query<Velocity, Transform> q) {
        foreach (var row in q) {
            var (v, t) = row;                         // or: var (entity, v, t) = row;
            t.PosX += v.X * dt;
            t.PosY += v.Y * dt;
        }
    }
}
  • Up to 4 components. The first is iterated; entities missing any other are skipped, so put the rarest first.
  • Query<A> yields A directly.
  • [Without(...)] applies to the system's Query parameters only.
  • Same rules as collections: the views write through; structural changes go through Commands.
Systems

Change tracking

Set "trackChanges": true in a component's JSON, then skip entities that didn't change since this system last ran:

public static void Update(HealthBarCollection bars) {
    for (int i = 0; i < bars.Length; i++) {
        if (!bars.Changed(i)) continue;            // only bars added or modified since last time
        Redraw(bars[i]);
    }
}

Untracked components report every slot as changed. For a Query, q.Changed(row.Index) covers the first component.

Systems

Entities, handles and structural changes

An entity id is a ulong handle: slot index plus a generation. When an entity dies its slot's generation changes, so an old handle you kept (a target, a follow) never reaches a different entity that reused the slot. World.IsAlive(id) just returns false.

ulong target = enemy.Target;
if (World.IsAlive(target) && Transform.TryGet(target, out var tt)) { /* chase */ }

Structural changes

Creating or destroying entities and adding or removing components can move component memory. Inside a system loop, do them through something deferred:

Inside a loopInstead of
cmd.Destroy(e), cmd.Remove(e, X.TypeId), cmd.Add(...)World.Destroy, X.Remove (these defer automatically, but prefer Commands for clarity)
X.AddDeferred(e)X.Create(e)
Scenes.Spawn(...)Scenes.Instantiate(...)

Event handlers, timers, physics and UI callbacks all run at the end of the frame, outside any loop, where anything is allowed.

Gameplay

Events

Events pass messages between systems without direct references. They go through the same queue C++ uses, so C++ and C# can talk both ways.

  1. Declare a struct with a unique id. Only unmanaged fields: numbers, bools, ulong entities, Vec2, nested blittable structs. No strings or lists.
  2. Subscribe in OnStart. Subscriptions are cleared when Play stops, so you never get duplicates.
  3. Publish from anywhere. Delivery happens at the next dispatch (start of the frame or after LateUpdate).
[ECSEvent(9001)]
public struct DamageEvent { public ulong Target; public int Amount; }

[ECSSystem(6100)]
public static class HealthSystem {
    public static void OnStart() {
        Events.Subscribe<DamageEvent>(e => {
            if (Health.TryGet(e.Target, out var h)) h.Value -= e.Amount;
        });
    }
}

// somewhere else:
Events.Publish(new DamageEvent { Target = enemy, Amount = 5 });

Keep the subscription to stop early: var sub = Events.Subscribe<T>(...); sub.Dispose();

Engine events you can subscribe to

EventIdFields
CollisionEvent0xFFFF0400Entity, Other, Normal, Point, Depth, Phase (0 enter, 1 stay, 2 exit), Trigger. Easier: the Physics.On* callbacks.
UIEvent0xFFFF0500Entity, Kind, Value. Easier: UI.OnClick etc.
AnimationFinished0xFFFF0200Entity, Clip, ClipName
FrameEndEvent0xFFFF0300DeltaTime, UnscaledDeltaTime. Easier: Session.FrameEnd.
Gameplay

Global state: Resources

For data that belongs to no entity (score, wave number, settings), use a plain class through Resources. Don't use static fields for per-entity state: put that in components.

public class GameState { public int Score; public int Wave = 1; public bool GameOver; }

Resources.Get<GameState>().Score += 10;          // created on first use

[PersistentResource]                              // survives Play/Stop (e.g. options)
public class Options { public float MusicVolume = 0.8f; }

Resources reset when Play stops, so every Play starts clean, unless marked [PersistentResource].

Gameplay

Scenes and prototypes

A prototype (.proto.json) is a reusable entity with its children, like a Unity prefab. Create one from the Project panel and edit it like a small scene.

Scenes.Load("Level2");                        // Scenes/Level2.scene.json, at the next frame boundary
Scenes.Reload();
ulong boss = Scenes.Find("Boss");            // by editor name, 0 if none

// spawn: safe anywhere, appears next frame boundary
Scenes.Spawn("Prototypes/Bullet", pos, bullet => {
    var rb = Rigidbody.Get(bullet);
    rb.Velocity = dir * 600f;
});

// immediate: only outside system loops
ulong e = Scenes.Instantiate("Prototypes/Pickup", 100, 50);

Scenes.DontDestroyOnLoad(player);            // survives scene changes
Scenes.Loaded += name => Debug.Log("loaded " + name);

Names are flexible: "Level2", "Scenes/Level2" and the full file name all work, and a bare prototype name is found anywhere in the project.

Gameplay

Parent and child

Nest entities in the Hierarchy panel, or at runtime:

Hierarchy.SetParent(sword, player);                 // keepWorld: true by default
Hierarchy.ClearParent(sword);
if (Hierarchy.TryGetParent(sword, out var owner)) { }
  • Transform is always world space. Move a parent by its Transform and children follow.
  • Move a child relative to its parent with its Parent.LocalX/LocalY/LocalRotation/LocalScaleX/LocalScaleY.
  • Destroying a parent destroys its children. Call ClearParent first to keep them.
  • The first SetParent on an entity adds a component immediately: call it from OnStart or a callback, not while looping over that entity.
Gameplay

Time, timers and cooldowns

Time.TimeScale = 0.5f;                        // slow motion; 0 = paused game
float real = Time.UnscaledDeltaTime;          // for menus while paused

Timers.After(2f, () => Debug.Log("2 seconds later"));
var spawner = Timers.Every(0.5f, SpawnEnemy);           // until cancelled
Timers.Every(1f, Tick, count: 3);                       // exactly 3 times
Timers.After(1f, Explode, owner: bomb);                 // skipped if bomb died first
Timers.DestroyAfter(bullet, 2f);
spawner.Cancel();

// a field in your system class:  static Cooldown s_dash = new Cooldown(1.5f);
if (Input.KeyPressed(Key.Space) && s_dash.TryUse()) Dash();
UIDraw.Rect(10, 40, 100 * s_dash.Progress, 6, Color.Cyan);  // cooldown bar

Timers use game time (TimeScale applies) unless unscaled: true. They fire at the end of the frame and are cleared when Play stops. For a per-entity cooldown store the time in a component: if (Timers.Now >= w.NextShot) { w.NextShot = Timers.Now + w.FireRate; Shoot(); }

Gameplay

Input

Vec2 move = Input.MoveVector;                         // WASD / arrows, length <= 1
float h = Input.Axis(Key.A, Key.D);
if (Input.KeyPressed(Key.Space)) Jump();
if (Input.KeyDown(Key.LeftShift)) speed *= 2;

var p = Input.Pointer;                                // mouse-left or first finger
if (p.Pressed && !UI.PointerOverUI) Shoot(Camera.MouseWorld);

for (int i = 0; i < Input.TouchCount; i++) {
    var t = Input.GetTouch(i);
    if (t.WasPressed) Debug.Log($"finger {t.Id} at {t.X},{t.Y}");
}
if (Input.KeyPressed(Key.Back)) OpenPauseMenu();     // Android back
  • Read input in Update. Pressed and Released last exactly one frame.
  • Coordinates are screen pixels, origin top-left, y down. For the world position use Camera.MouseWorld.
  • In the editor the Game panel must be focused or hovered, and positions are relative to it.
  • Input.Pointer works on both desktop and phones; prefer it for taps and clicks.
Gameplay

Physics

  1. Add a Collider (Box or Circle). Size 1,1 or Radius 0.5 covers the sprite quad.
  2. Add a Rigidbody to things that move. A Collider without a Rigidbody is a static wall.
  3. Tick IsTrigger for areas that detect overlaps but never push (pickups, goals).
  4. React in code with the callbacks below.
public static void OnStart() {
    ulong player = Tags.Find("Player");
    Physics.OnCollisionEnter(player, c => {
        if (Tags.Has(c.Other, "Enemy")) Events.Publish(new DamageEvent { Target = player, Amount = 1 });
    });
    foreach (var coin in Tags.FindAll("Coin"))
        Physics.OnTriggerEnter(coin, c => {
            if (!Tags.Has(c.Other, "Player")) return;
            Resources.Get<GameState>().Score++;
            World.Destroy(c.Entity);
        });

    Physics.NameLayer(3, "Enemy");
    Physics.SetLayerCollision(3, 3, false);        // enemies pass through each other
    Physics.Gravity = new Vec2(0, -980);           // platformer: also set GravityScale 1 on bodies
}

// queries
if (Physics.Raycast(origin, dir, 500f, out var hit)) DebugDraw.Line(origin, hit.Point, Color.Red);
var near = Physics.OverlapCircle(pos, 120f, Physics.Mask("Enemy"));
Physics.AddImpulse(e, new Vec2(0, 400));
  • Rigidbody GravityScale defaults to 0 (top-down). Set it to 1 for platformers.
  • Put Rigidbodies on root entities; a child's Transform is rewritten from its parent.
  • Rotation is not simulated. Colliders follow the Transform rotation for overlap tests.
  • Physics.DebugDraw = true outlines every collider.
Gameplay

Camera

Camera.Follow(player, speed: 6f);              // 0 = locked on
Camera.Zoom = 2f;
Camera.Shake(8f, 0.3f);
Camera.SetBounds(new Vec2(-2000, -1000), new Vec2(2000, 1000));
Vec2 world = Camera.MouseWorld;
Vec2 screen = Camera.WorldToScreen(enemyPos);  // e.g. a UI marker above an enemy

Add an entity with a Camera2D component to set the camera up in the editor (the Game view shows it even when stopped). These functions then write into that component. Without one, they drive a default camera.

Gameplay

Animation

Make a clip

  1. Select a sprite-sheet image in the Project panel.
  2. Set Columns and Rows (saved in the image's .meta), then click Create Animation Clip.
  3. Pick frames, fps and loop in the Inspector; the preview plays live.
Animations/Run.anim.json
{ "texture": "Assets/Images/Hero.png", "columns": 8, "rows": 4,
  "frames": [8, 9, 10, 11], "fps": 12, "loop": true }

Play it

Animator.Play(e, "Run");                         // by name or path; adds an Animator if missing
Animator.Play(e, "Attack", restart: true);
if (!Animator.IsPlaying(e, "Attack")) Animator.Play(e, "Idle");
Animator.OnFinished(e, clip => Animator.Play(e, "Idle"));   // non-looping clips
Gameplay

Audio

Each sound is an int handle to a native audio component. Create it once, then play it as often as you like.

static int s_jump, s_music;

public static void OnStart() {
    s_jump  = Audio.Create("Assets/Audio/sfx/jump.wav");                  // SFX group by default
    s_music = Audio.Create("Assets/Audio/theme.ogg", AudioGroup.BGM, 0.6f);
    Audio.Play(s_music);
}

public static void Update() {
    if (Input.KeyPressed(Key.Space)) Audio.Play(s_jump);
    if (Input.KeyPressed(Key.M)) {
        if (Audio.GetState(s_music).IsPaused) Audio.Resume(s_music); else Audio.Pause(s_music);
    }
}

From a component field of type Audio: Audio.Create(c.HitId).

TopicDetail
Formats.wav, .mp3, .flac, .ogg. An .ogg must contain Vorbis audio; Opus or FLAC inside .ogg fails (the log says which). Re-encode: ffmpeg -i in.ogg -c:a libvorbis -q:a 5 out.ogg.
MemoryClips are loaded fully into memory. Prefer .ogg for long music: a 3-minute .wav is about 30 MB.
TimingPlay queues the sound; it starts at the end of the frame.
Volume/pitchSetVolume / SetPitch apply on the next Play.
CleanupHandles are released when Play stops. Audio.Release(h) frees one earlier.
PreloadingAudio.Register(clip) loads it ahead of time to avoid a hitch on first play.
Background (Android)Sound pauses when the app leaves the screen and continues when it returns (engine app-state hook).
UI

Canvas UI

Build it in the editor

  1. Entity › UI › Canvas. Elements live under a Canvas and are laid out in reference pixels (default 1280×720), scaled to the screen.
  2. Add children: Panel, Image, Text, Button, Input Field, Toggle, Slider.
  3. Position with UIRect, which works like Unity's RectTransform: equal AnchorMin/AnchorMax pins to a point of the parent, different anchors stretch.
  4. Give buttons an Action name to hook them up by name from code.

The Game view draws the UI even when stopped, so you can lay it out without pressing Play.

Hook it up

public static void OnStart() {
    UI.OnClick(UI.Find("PlayButton"), () => Scenes.Load("Level1"));
    UI.OnClick("Quit", Application.Quit);                       // UIButton.Action == "Quit"
    UI.OnValueChanged(UI.Find("Volume"), v => SaveData.SetFloat("volume", v));
    UI.OnSubmit(UI.Find("NameField"), text => SaveData.SetString("name", text));
}

public static void Update() {
    UI.SetText(UI.Find("Score"), "Score: " + Resources.Get<GameState>().Score);
    if (UI.PointerOverUI) return;                               // click was on the UI
}

Canvas modes

SettingEffect
RenderMode ScreenSpaceOver the camera view (HUD, menus).
RenderMode WorldSpacePlaced in the world at the canvas entity; WorldScale = world units per reference pixel (health bars over heads).
Layer BehindSpritesDrawn before the sprites (backgrounds).
Match0 scales with width, 1 with height.
UI

Drawing a HUD in code

UIDraw draws in screen pixels from any system, without entities. Good for quick HUDs and debug overlays.

[ECSSystem(6300)]
public static class HudSystem {
    public static void Update() {
        var gs = Resources.Get<GameState>();
        float hp01 = 0.7f;                                        // your health, 0..1
        float s = UIDraw.ScreenHeight / 720f;                     // scale with the window
        UIDraw.Rect(16 * s, 16 * s, 200 * s, 16 * s, new Color(0, 0, 0, 0.5f));
        UIDraw.Rect(16 * s, 16 * s, 200 * s * hp01, 16 * s, Color.Red);
        UIDraw.Text("Score " + gs.Score, 16 * s, 40 * s, 24 * s, Color.White);
        UIDraw.Text("PAUSED", 0, 0, UIDraw.ScreenWidth, UIDraw.ScreenHeight, 64 * s,
                    Color.White, TextAlign.Center, TextVAlign.Middle);
    }
}
Data & files

Assets and files

string json = Assets.ReadText("StreamingAssets/waves.json");     // null if missing
byte[] raw = Assets.ReadBytes("Resources/map.bin");
string path = Assets.Find("Run");                                 // "Animations/Run.anim.json"
var size = Assets.TextureSize("Assets/Images/hero.png");
  • Paths are relative to the project folder and work the same in the editor, a desktop export and on Android.
  • Files you only load by name from code belong in Resources/ or StreamingAssets/, which are always exported.
  • Changing a file on disk reloads it in the running editor.

Image settings (.meta)

Select an image in the Project panel to set its sprite-sheet grid, filter (nearest for pixel art) and 9-slice border. They're saved next to it:

Hero.png.meta
{ "columns": 6, "rows": 1, "filter": "nearest", "border": [8, 8, 8, 8] }
Data & files

Save data

SaveData.SetInt("coins", coins);
SaveData.SetString("lastLevel", Scenes.Current);
SaveData.SetObject("settings", mySettings);       // any plain class
SaveData.Save();                                  // also automatic on Stop / Quit

int coins = SaveData.GetInt("coins", 0);
var s = SaveData.GetObject("settings", new Settings());
SaveData.Slot = "slot2";                          // switch save slot
WhereLocation
Editor<Project>/Library/SaveData/ (never touches real player saves)
Windows%APPDATA%\<Company>\<Product>
Linux~/.local/share/<Company>/<Product>
AndroidApp internal storage
Debugging

Logging and debug drawing

Debug.Log("spawned " + n);
Debug.LogWarning("low hp");
Debug.LogError("save corrupt");                  // kept in release, written to log.txt
Debug.LogException(ex);
Debug.Assert(hp >= 0, "negative hp");

DebugDraw.Circle(pos, attackRange, Color.Red);   // this frame
DebugDraw.Line(a, b, Color.Yellow, duration: 2f);
DebugDraw.Box(center, new Vec2(64, 32), Color.Green, angle: rot);
Physics.DebugDraw = true;                        // every collider
BuildWhat stays
EditorEverything.
Export with Strip Debug LogsLog, LogWarning, Assert and all DebugDraw calls are compiled out: they don't even run. LogError and LogException stay and go to log.txt in the save folder.
Export with Strip Debugger / ProfilerThe profiler, hang watchdog, crash reports, debug console, log capture and test runner are switched off at start-up. Profiler.* calls return at once. See Exporting.

Finding problems

  • Double-click a Console line to open the file at that line.
  • Console › Error Pause pauses Play on the first exception.
  • The editor also writes everything to Editor.log in its app-data folder (useful after a crash).
  • Every run of the editor and of the development game also writes logs/<executable>.log in its working directory (exported games don't): every C++ and C# line with time and frame number, including start-up before the editor UI exists. See Running headless for the options.
  • On Android, use Build › Logcat; your messages appear with the SDL/APP tag.
  • Slow frame or a freeze? Open the Watchdog panel: it shows which system took the time, and a hang report names the system the game is stuck in.
Debugging

Profiler and the Watchdog panel

Every ECS system is timed automatically, per phase, with the names the generator registered. You don't add anything to get the systems table. For your own code, time a block or record a value:

// or: using var _ = Profiler.Scope("Pathfinding");  (ends at the end of the block)
using (Profiler.Scope("Pathfinding")) {
    FindPath(start, goal);
}
Profiler.Counter("Enemies alive", enemies.Length);       // one value per frame, graphed
Profiler.Mark("Boss spawned");                           // event list + trace marker

double ms = Profiler.Stat("system_ms:EnemySystem");      // any profiler value by name (NaN if unknown)

The Watchdog panel

View › Watchdog, Ctrl+Shift+W, or click the FPS counter in the toolbar. It docks next to the Console.

TabShows
OverviewGraphs: frame time against the frame budget, CPU, memory, C# heap, allocations, entities, audio, GC. Pin any counter.
SystemsSortable table: avg / recent / max / last ms, % of the frame, entities, calls. Click a row for the per-phase breakdown. The checkbox turns a system off.
TimersThe same for engine phases and your Profiler.Scope blocks.
MemoryProcess memory, native heap, C# heap and collections, leak analysis (memory that keeps growing).
Counters / EventsEvery counter; hitches, hangs, GCs, script errors, your Profiler.Mark lines.
Tests / ConsoleRun a test file; type any console command.

The header has Record trace (open the file in ui.perfetto.dev for a timeline of every frame, system and scope) and Save report (text report in <Project>/Profiler/).

Turning it off

Collecting costs a little per system and per C++ allocation, which shows when you mass-spawn thousands of entities. Preferences › Debug › Profiler (also a combo in the Watchdog header) decides when it runs:

ModeCollects
While Watchdog is open (default)Everything, only while the panel is visible.
AlwaysEverything, all the time, so the history is there when you open the panel.
OffFrame time and FPS only.

The hang watchdog and crash reports stay on in every mode; they cost nothing per frame.

Debugging

Debug console commands

One set of commands works everywhere: the Watchdog Console tab, a terminal (headless runs), test scripts and code. help lists them all, including the ones scripts added.

CommandDoes
systems [avg|max|name], system <name> on|offSystems table; switch a system off and on
stats, mem, leaks, eventsFrame times, memory, leak analysis, recent events
entities, components [filter], audio, gcLive entities, component counts, FMOD channels, C# heap
scene load <name>, spawn <prototype> [x y]Through Scenes
pause, step [n], resume, timescale <x>Freeze the game logic, single-step, slow motion
trace start [file], trace stop, report [file]Chrome trace for part of a run; full report
mark <text>, log level <lvl>, quit [code]

Your own commands

[ECSSystem(6400)]
public static class CheatSystem {
    public static void OnCreate() {
        DebugConsole.Register("heal", "heal <amount>", "heal the player", args => {
            var hp = Health.Get(Scenes.Find("Player"));
            hp.Value += int.Parse(args[1]);              // args[0] is the command name
            return "player hp " + hp.Value;               // shown in the console; throw to report a failure
        });
    }
}

string table = DebugConsole.Execute("systems max");      // run any command from code

Commands from game scripts are removed automatically when the scripts reload, so registering in OnCreate never duplicates them.

Testing

Testing at a glance

The engine has four kinds of tests. One command builds everything and runs all of them, the same way on your machine and on a build server, and exits non-zero when anything fails.

KindWhereWhat runsUse it for
Unit testsTests/unit/*.cppGAM200_UnitTests: no window, no engine, no C#. About 50 ms.Core C++ logic: ECS storage, registry, system scheduling, events, JSON. Every case is also a leak check.
Test scriptsTests/*.gtestThe real game, headless, with the start scene loaded.“The scene runs 2 seconds with no errors, above 20 fps, EnemySystem under 2 ms.”
C# test assembliesyour class library .dllThe real game, headless.Gameplay checks written in C#: the player exists, the box falls.
Native test modulesTests/native/*.cppThe real game, headless.C++ checks against the running engine through GamTestApi.h.

The two commands

From a clean checkout (CMake 3.16+, a C++20 compiler, Python 3.9+, the .NET SDK):

python Tests/ci.py            # build Debug + Release (+ ASan on Linux/macOS), run every test
python Tests/ci.py --quick    # Debug only: unit tests + leak self-check, about a minute after a build

See Build and test everything for the options and where the results go.

Which kind of test should I write?

You changedWrite
ECS internals, the event bus, the serializer, any C++ that runs without a windowA unit test. It runs in milliseconds and names the exact case that broke.
A system, a component or gameplay that needs a sceneA .gtest script, or a C# test if you need to inspect entities.
Performance (“must stay under 2 ms”)An expect system_ms:YourSystem < 2 line in a .gtest. Measure in a Release build.
Something that only breaks after a while (a hang, a growing heap)A .gtest with a long wait plus expect hangs == 0 and expect rss_mb < ....
Testing

Running headless

Any build of the game or the editor runs without a window. That is how tests run on a build server, but it is also the quickest way to profile a scene. The engine runs normally: the scene loads, systems tick, audio plays (silently if there is no sound device), and the profiler records.

# the start scene for 10 seconds: a summary line every second, a full report at the end
GAM200_Game --project MyGame/MyGame.astge --headless --seconds 10

# 600 frames with a Chrome trace and a report file
GAM200_Game --project MyGame/MyGame.astge --headless --frames 600 --trace trace.json --report report.txt

# pure simulation: no drawing, every frame advances exactly 1/60 s (repeatable results)
GAM200_Game --project MyGame/MyGame.astge --headless --no-render --fixed-dt 0.0166667 --frames 3600

# the editor executable without a window behaves like the game (no editor UI)
GAM200_Editor --project MyGame/MyGame.astge --headless --test Tests/smoke.gtest

The executables are in local_desktop_build/<Config>/ after setup_build, or build/ci-<config>/<Config>/ after Tests/ci.py. --project picks the game; without it the engine also accepts a bare *.astge argument or the GAM_PROJECT environment variable.

What you see

First a start-up table: how long each step took (SDL, window and GL, C# runtime, assets, renderer, audio, OnCreate). Then a summary line every second, with the three most expensive systems at the end:

[prof] fps  60.0 | ms avg  2.41 p99  4.80 max  6.12 | cpu  38% | rss 212.4MB | ent 214 | top EnemySystem 0.42ms, PhysicsSystem 0.31ms

When the run ends, a full report: frame-time percentiles, the systems and timers tables, memory, leak trends, hitches, hangs and test results.

Options

The most useful ones. GAM200_Game --debug-help prints all of them; the same list can also be set once in the GAM_DEBUG_ARGS environment variable (the command line wins).

OptionEffect
--headless / --windowedNo visible window / force a window. Headless is automatic on Linux when there is no display.
--frames N, --seconds SQuit after N frames or S seconds. With --test, the run ends when the last test finishes.
--fps NFrame limiter (headless default 60, 0 = as fast as possible).
--fixed-dt SEvery frame advances exactly S seconds: the same input gives the same result every run.
--no-renderKeep the GL context (assets load) but skip drawing: pure simulation.
--trace file.jsonChrome trace of every frame, system and scope. Open it in ui.perfetto.dev.
--report file, --report-json fileWrite the final report to a file (text / machine readable).
--log-file file, --no-log-file, --log-level lvlSee logs below.
--hang-timeout S, --hang-abort SA frame longer than S seconds is a hang (default 5). With --hang-abort a hang ends the run with exit code 124 instead of blocking a build server forever.
--exec "cmd; cmd"Console commands to run on the first frame, e.g. --exec "scene load Level2".
--consoleType console commands into the terminal while it runs (on by default in an interactive headless terminal).

Where it can run

MachineWhat to know
Windows or Linux desktopWorks as is. --headless keeps the window hidden.
Linux server, no displayHeadless switches on by itself. Install Mesa (sudo apt install libegl1 libgl1-mesa-dri) to render on the CPU, or run under xvfb-run with --video-driver x11.
No OpenGL at allHeadless runs still start: the engine prints one warning and runs everything except drawing. The stats gl and rendering are 0, so a test can check for it.

Logs

Every run writes its log to the terminal and to logs/<executable name>.log in the working directory (for example logs/GAM200_Editor.log), rewritten on each start. It holds every line of the process: C++ LOGI / LOGE, std::cout, C# Debug.Log and the debugger's own lines, with time and frame number. The first line of the main loop says where the file is. --log-file path chooses another file, --no-log-file turns it off. On Android the log is logcat.

Exported games never write it, stripped or not: the game doesn't create logs/ in a player's install folder. --log-file still works there when a tester asks for it, and errors from scripts still go to log.txt in the save folder (Debug.LogError).

When start-up fails

EngineCore::TryInitialize returns false when the engine cannot run, and says why in one sentence. Instead of an empty window you get that sentence in the log and, in a windowed run, an error dialog naming the log file. The process exits with code 1.

Message starts withUsual cause
SDL could not start the video subsystemNo display or graphics driver. Use --headless on a server.
could not create an OpenGL 4.5 core contextAn old or missing GPU driver. Update it.
the C# script runtime did not startCSharpRuntime/ next to the executable is missing or stale, or .NET is not installed. Run build_csharp (CMake does it as part of the build). The lines above it in the log say which step failed.
shader 'Sprite' failed to compile or linkA broken Assets/Shader/*.vert / .frag. The ShaderHelper error above it has the GLSL compiler output.

Exit codes

CodeMeaning
0Ran to the end; every test passed
1Start-up failed (see above)
NN tests failed (capped at 100)
2Tests were requested from a build exported with Strip Debugger / Profiler
124Stopped by --hang-abort after a hang (the report names the system it was stuck in)
134 / 139Crashed; crash_<pid>.log has the stack trace
Testing

Engine test cases

Test cases given with --test run one after another while the real scene plays, so they see the real systems, physics, audio and profiler. When the last one finishes the engine quits; the exit code is the number of failures.

GAM200_Game --project MyGame/MyGame.astge --headless --test Tests/smoke.gtest --junit results.xml --hang-abort 30
GAM200_Game --headless --test-dir Tests --junit results.xml       # every test file in a folder, sorted
Tests/run_tests.sh [game executable] [project.astge]             # run_tests.bat on Windows: the team's standard set
--test acceptsRuns as
*.gtest, *.txtA test script (below).
*.sh, *.bat, *.cmdWith #gam: / REM gam: lines: those lines as a test script (a launcher can test itself, below). Without: run as a program for this platform, exit code 0 passes.
a .NET *.dllC# tests (detected automatically).
a native *.dll / *.soA native test module.
shell:<command line>Any program; exit code 0 passes.

Test scripts (.gtest)

One command per line; # starts a comment. Any console command works, plus these:

CommandDoes
name <text>Name in the results and JUnit (default: the file name).
timeout <s>Fail if the test takes longer (default 120, or --test-timeout).
wait <frames>, wait <s>sLet the game run. wait 120 = 120 frames, wait 2s = 2 seconds.
expect <stat> <op> <value>Compare a stat: < <= > >= == !=.
assert_no_errorsFail if anything logged an error since the test started (C++ or C#, including script exceptions).
fail <message>, passEnd the test now.
shell <command>Run a program; a non-zero exit code fails the test.

A script stops at its first failure. An unknown command is skipped with a warning; --strict-scripts makes it a failure.

Tests/smoke.gtest
# Smoke test: the start scene runs for a while without errors, hangs or a collapsing frame rate.
name    Smoke: start scene runs cleanly
timeout 60

profile reset                  # any console command works in a script
wait 120                       # frames; "wait 2s" = seconds
expect frame        >= 120
expect fps_avg      >  20      # <  <=  >  >=  ==  !=
expect hangs        == 0
assert_no_errors               # no error logged since the test started (C++ or C#)

mark "smoke: half way"
wait 2s
expect frame_ms_p99 <  100
expect rss_mb       <  2048
Switching systems off, stepping and tracing
name    Systems: toggle + timings
wait 30
systems                         # prints the systems table into the log
expect systems_run >= 1
system EnemySystem off          # the game keeps running without it
wait 30
system EnemySystem on
pause
step 10                         # exactly 10 frames, then paused again
resume
trace start systems_trace.json  # 60 frames of timeline for ui.perfetto.dev
wait 60
trace stop
assert_no_errors

Stat names

The same names work in expect, Profiler.Stat, TestAssert.Stat and native modules' api->Stat.

StatValue
frame, fps, fps_avg, frame_ms, frame_ms_p99Frame counter and frame times since the last profile reset.
system_ms:<System>, zone_ms:<Scope>One system's or Profiler.Scope's time, by the name in the Systems table.
systems_run, entitiesSystems that ran last frame; live entities.
rss_mb, native_mb, managed_mbProcess memory, C++ heap, C# heap.
errors, hitches, hangsCounts since start.
audio_channels, gl, renderingFMOD channels playing; 1 when OpenGL / drawing is available.
your Profiler.Counter namesLast value; add .avg, .min or .max for the history.

A launcher that is also a test

Lines starting with #gam: (shell) or REM gam: (batch) are comments to the shell and test commands to the engine. Tests/run_tests.sh passes itself to --test this way, so the launcher checks that it works too.

#!/bin/sh
# Lines starting with "#gam:" are comments to the shell, test commands to the engine.
#gam: name  Shell-launched smoke test
#gam: wait 60
#gam: expect fps_avg > 20
#gam: assert_no_errors
exec "$GAME" --headless --test "$0" ...

C# tests

Put tests in a class library that references EngineScripts.dll and pass the built .dll to --test. Static [EngineTest] methods run first, then every IEngineTest class runs frame by frame until all report done.

public static class QuickChecks {
    [EngineTest] public static void PlayerExists() => TestAssert.True(Scenes.Find("Player") != 0, "no Player");
}

public sealed class BoxFalls : IEngineTest {                // runs across frames
    ulong _box; float _y0, _t;
    public void Start() { _box = Scenes.Find("Box"); _y0 = Transform.Get(_box).PosY; }
    public bool Update(float dt) {                          // true = finished
        if ((_t += dt) < 1f) return false;
        TestAssert.True(Transform.Get(_box).PosY < _y0, "box did not fall");
        return true;
    }
}

Native test modules

A small shared library that only needs Src/Engine/Debug/GamTestApi.h. Every *.cpp in Tests/native/ becomes one module (cmake -S Tests/native -B build/native && cmake --build build/native; ci.py does this for you), and run_tests picks up whatever is in Tests/native/build/. Keep CMake’s own files under build/ (git-ignored): only the finished modules go to Tests/native/build/.

Tests/native/FramePacingTest.cpp
#include "GamTestApi.h"

static int s_frames = 0;
extern "C" GAM_TEST_EXPORT const char* GamTest_Name(void) { return "Frame pacing"; }

extern "C" GAM_TEST_EXPORT int GamTest_Init(const GamTestApi* api) {
    GAM_CHECK(api, api->GetNative("Time.GetState") != nullptr);   // any ENGINE_NATIVE, same table as C#
    GAM_CHECK(api, api->Execute("profile reset") == 0);           // any console command
    return 0;                                                      // 0 = ok
}

extern "C" GAM_TEST_EXPORT int GamTest_Update(const GamTestApi* api, float dt) {
    if (++s_frames < 120) return 0;                                // 0 = keep going
    GAM_CHECK(api, api->Stat("fps_avg") > 20.0);
    return 1;                                                      // 1 = done (failed if a check failed)
}
api->Does
Check(ok, what, file, line) / GAM_CHECK(api, cond)Records a failure when false.
Stat(name)Any stat above (NaN if unknown).
Execute(cmd), LastResult()Run a console command (0 = ok) and read its text output.
GetNative(name)Any ENGINE_NATIVE function, the same table C# uses.
Log(level, msg), Frame(), Time()Log (0 info, 1 warning, 2 error), engine frame, seconds since the test started.

Results

--junit file.xml writes JUnit XML that any CI server can show. Tests/run_tests.* writes JUnit, the text and JSON reports, a trace and the log to test-results/ (or the folder in GAM_TEST_OUT). The exit codes are in Running headless.

Testing

Unit tests

GAM200_UnitTests tests the engine's core C++ with nothing else running: no window, no OpenGL, no C# runtime, and it does not even link EngineCore. It builds in seconds, runs in about 50 ms, and names the exact case that broke.

AreaCovers
Sparse setsSparseSet (set / get / remove / growth / iteration) and UntypedSparseSet (raw bytes, swap-remove, change ticks). SparseSetTests.cpp
RegistryEntity ids and recycling, generation handles, typed and C# components, the C# release hook on destroy. RegistryTests.cpp
SystemsPhase / order, lazy OnStart, enable toggles, deferred destroy and add, change tracking, profiler timing, script hot reload. SystemDispatchTests.cpp
ViewsView<A, B> queries. ViewTests.cpp
EventsTyped events, raw C# channels, unsubscribing during dispatch, the managed sink and reset. EventSystemTests.cpp
JSONJsonWriter / JsonReader round trip, defaults, comments, BOM, broken files. JsonSerializerTests.cpp

Running them

cmake --build build --target GAM200_UnitTests      # seconds: it does not need EngineCore or C#
build/Debug/GAM200_UnitTests                         # everything
build/Debug/GAM200_UnitTests Registry View           # only cases whose name contains "Registry" or "View"
build/Debug/GAM200_UnitTests --list                  # names only
build/Debug/GAM200_UnitTests --junit unit.xml        # JUnit XML for CI
ctest -C Debug --test-dir build                      # the same through CTest
A failing case shows the file, line, expression and both values
Running 47 unit test case(s) with per-test leak checking
[ RUN      ] SparseSet: remove keeps every other key mapped to its own value
[       OK ] SparseSet: remove keeps every other key mapped to its own value (0.00 ms)
[ RUN      ] Registry: destroyed ids are recycled and handles to the old entity go stale
[  FAILED  ] Registry: destroyed ids are recycled and handles to the old entity go stale (1.23 ms)
    RegistryTests.cpp:63: newHandle != handle  (4294967297 != 4294967297)
...
46 of 47 unit test case(s) passed in 41.2 ms -- 1 FAILED

Exit code: the number of failed cases (0 = all passed). Some cases test error paths on purpose, so lines such as [C++ ECS] Error: AddComponentRaw on dead entity 5 in the output are expected; only [ FAILED ] means something broke.

Writing a test

Add a .cpp file to Tests/unit/ (or a case to an existing one) and re-run CMake. Every .cpp in the folder is part of the executable.

Tests/unit/RegistryTests.cpp
#include "UnitTest.h"
#include "ECSRegistry.h"
#include <memory>

namespace { struct Health { int hp = 0; }; }             // test-only component

TEST_CASE("Registry: removing a component keeps the others") {
    auto reg = std::make_unique<ECS::Registry>();         // ~4 MB: keep it off the stack
    const ECS::EntityID a = reg->createEntity(), b = reg->createEntity();
    reg->addComponent(a, Health{ 10 });
    reg->addComponent(b, Health{ 20 });

    reg->removeComponent<Health>(a);

    CHECK(!reg->hasComponent<Health>(a));
    REQUIRE(reg->hasComponent<Health>(b));                 // stops here if false: the next line would crash
    CHECK_EQ(reg->getComponent<Health>(b)->hp, 20);
}
MacroDoes
TEST_CASE("Area: what it checks")A test case. Start the name with the area so filters work.
CHECK(expr)Records a failure and keeps going.
REQUIRE(expr)Records a failure and leaves the test. Use it when the next line would crash otherwise.
CHECK_EQ, CHECK_NE, CHECK_LT, CHECK_GT, REQUIRE_EQComparisons that print both values. Signed and unsigned integers compare by value.
FAIL("message")Fails and leaves the test.
HIDDEN_TEST_CASE(...)Only runs with --run-hidden <text> (used by the leak self-check).

Code that calls the engine

The ECS calls two engine services: ScriptEngineHost::GetExports() (so C# can free a component's strings and lists) and the profiler's Begin/EndSystem. In the unit tests they are small doubles in Tests/unit/EngineSeams.cpp that a test can observe or fill in:

namespace {
    int g_released = 0;
    void CDECL_CONV OnReleased(uint64_t, uint32_t, void*) { ++g_released; }
    // puts the null exports table back even if a REQUIRE leaves the test early
    struct ExportsGuard { ExportsGuard() { TestSeams::ResetExports(); } ~ExportsGuard() { TestSeams::ResetExports(); } };
}

TEST_CASE("Registry: destroying an entity lets C# release its component") {
    ExportsGuard guard;
    ScriptEngineHost::GetExports().OnEntityComponentDestroyed = &OnReleased;   // what C# would install
    // ... create an entity with a managed component, destroy it ...
    CHECK_EQ(g_released, 1);
}

TestSeams::Profiler() counts system dispatches the same way. If the ECS starts calling another engine function, the unit test link fails with an undefined symbol: add a double for it to EngineSeams.cpp.

Testing

Leak checking

Every unit test case is also a leak check: whatever a test allocates must be freed by the time it returns, or the case fails with LEAK: n block(s) .... A leak fails the run exactly like a wrong value.

CheckWhereCatchesReports
Per-test block countEvery buildnew without delete in the test, or in engine code it callsThe test case that leaked
MSVC CRT debug heapWindows, DebugAlso malloc leaks (rapidjson, SDL)Each leaked block’s number and size; --break-alloc <n> stops the debugger on it
AddressSanitizer + LeakSanitizerLinux / macOS, asan configurationAny unreachable block in the whole run, plus memory errors (use after free, overflows)A stack trace to the allocating line

Proof that it works

Tests/unit/LeakCheckSelfTest.cpp holds two hidden cases that leak on purpose. Tests/ci.py runs them every time and only passes when the leak checker fails that run, so a broken leak checker can never go unnoticed.

$ build/Debug/GAM200_UnitTests --run-hidden leak-demo
[  FAILED  ] leak-demo: a test case that forgets to delete one block (0.00 ms)
    LEAK: 1 block(s) allocated with new in this test case were never deleted
[  FAILED  ] leak-demo: a container that is never destroyed (0.00 ms)
    LEAK: 2 block(s) allocated with new in this test case were never deleted
0 of 2 unit test case(s) passed -- 2 FAILED                     (exit code 2)

Finding a leak

  1. Run only the failing case: GAM200_UnitTests "name of the case".
  2. On Windows, run the Debug build. The CRT debug heap dump above the failure lists each leaked block as {1234} normal block at .... Run the same case under the Visual Studio debugger with --break-alloc 1234: it stops on the line that made that allocation.
  3. On Linux, build the sanitizer configuration and run it. LeakSanitizer prints the stack of each leaked allocation.
cmake -S . -B build/asan -G Ninja -DCMAKE_BUILD_TYPE=Debug -DGAM_SANITIZE=address,undefined
cmake --build build/asan --target GAM200_UnitTests
LSAN_OPTIONS=suppressions=Tests/sanitizers/lsan.supp build/asan/Debug/GAM200_UnitTests
LeakSanitizer output
==10727==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 64 byte(s) in 1 object(s) allocated from:
    #0 0x7f... in malloc
    #1 0x55... in CountedAlloc Tests/unit/UnitTestMain.cpp:37
    #2 0x55... in operator new[](unsigned long) Tests/unit/UnitTestMain.cpp:65
    #3 0x55... in LeakOneBlock Tests/unit/LeakCheckSelfTest.cpp:15      <- the line that leaked
SUMMARY: AddressSanitizer: 488 byte(s) leaked in 3 allocation(s).

The whole engine

The unit tests cover the core logic. For a run of the whole game, configure with -DGAM_SANITIZE=address,undefined (Linux / macOS) and run it headless; Tests/sanitizers/lsan.supp silences leaks that belong to third-party libraries (SDL, FMOD, the .NET runtime, Mesa), never engine code. The engine's own report also prints after shutdown: N more native allocations alive. That number includes statics and caches, so compare it between runs rather than expecting 0.

Testing

Build and test everything (ci.py)

Tests/ci.py builds every configuration and runs every test without asking anything, so a build server can run the exact command you run. It exits with 0 only when every step passed and 1 otherwise.

python Tests/ci.py                         # everything
python Tests/ci.py --quick                 # Debug only: unit tests + leak self-check
python Tests/ci.py --configs debug asan    # pick configurations
python Tests/ci.py --mutation-check        # also prove the tests catch deliberate bugs
python Tests/ci.py --list                  # print the plan, run nothing

Configurations

NameBuildsRuns
debugEverything, DebugUnit tests, leak self-check, native test modules, project export, headless engine tests (Tests/run_tests.*) on the exported game.
releaseEverything, ReleaseUnit tests, project export, headless engine tests on the exported game.
asanUnit tests with AddressSanitizer, LeakSanitizer and UBSan (Linux / macOS)Unit tests, leak self-check.

The default is all three (Windows: debug and release; there the CRT debug heap does the leak checking). Each configuration builds into build/ci-<name>/, so they never overwrite each other or your own build folder.

Options

OptionEffect
--quickDebug only, no engine run.
--no-engineSkip the project export and the headless engine tests.
--no-exportRun the engine tests on the build folder’s GAM200_Game with --project instead of exporting first. Faster, but the project’s scripts only load if an editor compiled them into that build folder (see below).
--project <path>Game project for the engine tests: the project folder or its *.astge file, anywhere on disk. Relative paths count from where you run the command, then from the repository. Default: GAM_PROJECT, else the first project inside the repository (MyGame/, projects/DataTest/, ...). The summary says which one ran.
--mutation-checkSee below.
--results dirOutput folder (default test-results).
--timeout sA step running longer than this is killed and fails (default 3600).
--quietOnly print the step results; full output is still in the logs.

How the engine tests get your scripts

A game runs the project’s compiled scripts from CSharpRuntime/Game/GameScripts.dll next to the executable, and only an editor writes that file. A fresh build folder such as build/ci-debug has never had an editor open a project, so a game started from it knows no components and runs no systems (unknown component 'Transform' skipped, systems_run stays 0).

So for every engine configuration, ci.py first exports the project with that configuration’s editor, exactly like File › Build (GAM200_Editor --build), into build/ci-<config>/export/<Project>/, and runs the engine tests on the exported game from its own folder, the way a player starts it. The tests then check the project’s own scripts, compiled the way a player gets them.

  • Your project is left as it was. Opening a project makes the editor rewrite ProjectSettings/Build.json, GameScripts.csproj and .gitignore; ci.py puts all three back byte for byte afterwards, also when the export fails, and warns if anything else in the project changed.
  • Strip Debugger / Profiler turns tests off in an exported game, so the test export always has it off (and keeps the console window). The summary says when your Build Settings had it on. Your real exports are not affected.
  • The export runs headless (GAM200_Editor --headless --build): no window, no display, no OpenGL, so it works the same on a Linux build server. An editor built before headless export existed is detected within a second; ci.py then exports with an editor window instead (xvfb-run on Linux).
  • Logs: test-results/<config>/export.log (what the editor printed) and export-editor.log (its full log).

Testing a project that lives somewhere else

python Tests/ci.py --project "D:/My Games/BossRush"                 # the project folder
python Tests/ci.py --project "D:/My Games/BossRush/BossRush.astge"   # or its .astge file

# PowerShell: the same project for every run in this window
$env:GAM_PROJECT = "D:/My Games/BossRush"
python Tests/ci.py

Put paths with spaces in quotes. A wrong path, or a folder without a *.astge, stops ci.py straight away instead of running without a project.

Running it on Windows

You need Python 3.9+, CMake, Visual Studio with Desktop development with C++, and the .NET SDK, all on PATH. A normal PowerShell or Command Prompt in the repository root is enough: CMake finds Visual Studio itself.

python Tests\ci.py --quick          # Debug unit tests + leak self-check
python Tests\ci.py                  # Debug + Release, headless engine tests included
echo $LASTEXITCODE                  # PowerShell (cmd: echo %ERRORLEVEL%): 0 = everything passed
On WindowsWhat to expect
asan configurationSkipped with a message: AddressSanitizer runs on the Linux server. Debug builds use the MSVC CRT debug heap for leak checking instead.
First runSlow: it configures and builds build/ci-debug and build/ci-release, C# runtime included. Later runs only rebuild what changed.
Headless without Mesa warningNormal on Windows: the engine tests run without the renderer. C# systems, tests and the profiler still run.
Debugging a unit testEach build/ci-* folder has a .sln. Set GAM200_UnitTests as the startup project and put the test’s name in Debugging › Command Arguments.

Results

End of a run
==========================================================================================
  PASS  debug: configure                               4.1 s
  PASS  debug: build everything                       96.3 s
  PASS  debug: unit tests                              0.2 s  47 of 47 unit test case(s) passed in 41.2 ms
  PASS  debug: leak checker self-check                 0.1 s  deliberate leak was reported and failed the run as expected
  PASS  debug: native test modules                     2.0 s
  FAIL  debug: engine tests (headless)                64.8 s  1 of 4 test case(s) failed
  ...
==========================================================================================
[ci] 11/12 steps passed in 412 s. Logs and JUnit XML: test-results                (exit code 1)
test-results/
  summary.json                 every step: config, name, ok, seconds, detail
  debug/
    configure.log  build.log
    unit-tests.log  unit-junit.xml
    leak-selfcheck.log
    native-configure.log  native-build.log
    export.log  export-editor.log  the project export (editor --build)
    engine-tests.log
    engine/                    junit.xml  report.txt  report.json  trace.json  engine.log  (crash / hang dumps)
  release/  ...
  asan/     ...
  debug/mutations/             one build + test log per deliberate bug (--mutation-check)

build/ci-<config>/             build output, git-ignored (Windows: a <Config> folder with GAM200_Game.exe, CSharpRuntime, ...)
build/ci-<config>/native/      CMake files of the native test modules
build/ci-<config>/export/      the exported game the engine tests run
Tests/native/build/            the native test modules themselves (run_tests loads them from here)

Proving the tests catch regressions

--mutation-check breaks the engine on purpose, one bug at a time, from Tests/unit/mutations.json. For each one it patches the source, rebuilds the unit tests, and requires the run to fail. A bug that survives means no test protects that behaviour yet. The real source is always restored, including after Ctrl+C or a killed run.

One entry of Tests/unit/mutations.json
{
  "name": "SparseSet::remove forgets to re-point the element it moved",
  "file": "Src/ECS/SparseSet.h",
  "find": "sparse[backEntityID] = di;",
  "replace": "/* mutation: moved element keeps its old index */"
}

find must appear exactly once in file. When someone edits that line, the entry fails with a clear message: update it to match.

Keep it out of git

Everything a run creates is ignored by the repository’s .gitignore: build/, test-results/, logs/, *.dmp and the *.ci-mutation-backup files an interrupted --mutation-check can leave. If git lists files from a test run, the .gitignore is out of date: don’t commit them.

On a build server

Install the same tools, check out the repository, run python Tests/ci.py, and keep test-results/ as the build's artifacts (the *junit*.xml files are what most CI servers display). Nothing else is needed: no editor, no display, no prompts. On a Linux server add Mesa for rendering, or the engine tests run without drawing.

Editor

Editor tools

Put scripts in <Project>/Editor/. They compile with your scripts, only in the editor, reload on save, and are never shipped. Draw with Dear ImGui through Engine.UI.ImGui.

Editor/LevelTools.cs
using System.Linq;
using Engine.Core;
using Engine.Editor;
using Engine.UI;
using GeneratedECS;

[EditorWindow("Tools/Level Stats")]
public class LevelStats : EditorWindow {
    public override void OnGUI() {
        ImGui.Text($"Entities: {Selection.All.Count()}");
        if (ImGui.Button("Select player"))
            Selection.Select(Selection.Find(n => n.Name == "Player"));
    }
}

public static class MyMenus {
    [MenuItem("Tools/Create Spawn Point")]
    static void CreateSpawn() => Selection.CreateEntity("SpawnPoint");
}

[CustomInspector(typeof(GeneratedECSRegistry.Enemy))]
public class EnemyInspector : ComponentInspector {
    public override bool OnInspectorGUI() {
        bool changed = DrawDefault();                       // the normal fields
        if (ImGui.Button("Full health")) { SetFloat("Health", 100); changed = true; }
        return changed;                                     // true = undoable edit
    }
}

A tool that throws is logged with its file and line, and the editor's UI state is repaired.

Shipping

Exporting

File › Build Settings (Ctrl+Shift+B): product name, company, version, icon, start scene, window size, fullscreen, vsync, platform, output folder, and the two strip options below.

What goes into a build

  • The start scene and every scene, with everything they reference.
  • Every .anim.json clip and its texture.
  • Files named as a path in a script string, e.g. Audio.Create("Assets/Audio/jump.wav").
  • Everything under Resources/ and StreamingAssets/.
  • With Include All Assets ticked: every project file.
PlatformOutput
Windows / LinuxBuilds/<Platform>/ with the game executable, engine libraries, CSharpRuntime/ (release scripts, no Editor code), Assets/ and Data/ (your files and the manifest). Cross-export needs the other OS's engine build.
AndroidAn APK built with Gradle, optionally installed on the connected phone. C# runs on the .NET runtime packaged in the APK. One-time setup: Build Settings › Android › Set up.

Release builds

OptionRemovesKeep it off when
Strip Debug LogsDebug.Log, LogWarning, Assert and DebugDraw calls (compiled out). Errors are still logged.You want log output from testers.
Strip Debugger / ProfilerAt start-up: profiler, hang watchdog, crash reports, debug console, test runner. Profiler.* and DebugConsole.* do nothing.The build is graded or tested from the command line (--test then exits with code 2).

Both are off by default. Tick both for a fully quiet release. Engine options such as --headless and --frames keep working.

Android checklist

  • Press Logcat in the Build window before starting the game to see its log.
  • Folder names that differ only in capitalisation from the engine's (Audio vs audio) are handled; file names must match exactly.
  • Audio .ogg files must be Vorbis.
  • Large GIFs are shrunk to fit phone memory (manifest key animatedTextureBudgetMB).

Command line

GAM200_Editor --project MyGame/MyGame.astge --build Linux [--output dir]              # opens the editor, exports, closes
GAM200_Editor --project MyGame/MyGame.astge --build Linux [--output dir] --headless   # no window: build servers / CI

Both use the project’s Build Settings, with the target and output folder from the command line. With --headless the editor UI never starts and the export runs on its own (HeadlessBuild.cs), so no display or OpenGL is needed. The exit code is 0 when the export succeeded and 1 when it failed; the reason is in the [Build] lines of the log.

Shipping

Ids and rules of thumb

Id ranges

KindRangeExample
Engine componentsbelow 1000 (built-ins 100–127)Transform = 100
Game components1000 and upEnemy = 1100
Engine systems900–999, reservedPhysicsSystem = 910
Game systems5000 and upEnemySystem = 6000
Eventsagree with the team; 0xFFFF0000 and up are the engine'sDamageEvent = 9001

Ask before taking a range: two components or systems with the same id are a compile error.

Rules of thumb

  • Data in components, behaviour in systems. No static fields for per-entity state.
  • Subscribe to events and callbacks in OnStart.
  • Use Entity fields, not names, for links that must survive saving.
  • Structural changes inside loops go through Commands, AddDeferred or Scenes.Spawn.
  • Commit Scenes/ Prototypes/ Components/ Scripts/ Editor/ ProjectSettings/ and your art. Ignore Library/ Builds/ obj/ bin/.
  • Never put test content in the engine folder.
Shipping

Troubleshooting

SymptomCause and fix
My change does nothingThe script failed to compile and the old version is still running. Check the Console.
Entity #12 has no Enemy componentEnemy.Get throws when the component is missing. Use TryGet.
Cannot modify members of 'e' because it is a 'foreach iteration variable'Copy it first (var en = e;) or use a for loop with the indexer.
Key presses are missed or doubledYou read KeyPressed in OnFixedUpdate. Read it in Update.
Event handler runs twiceYou subscribed in OnCreate or Update. Subscribe in OnStart.
Odd crashes or wrong data after adding a component in a loopUse AddDeferred / Commands inside loops.
Spawned entity isn't there yetScenes.Spawn creates it at the next frame boundary; use the done callback.
Bodies don't fallRigidbody GravityScale defaults to 0. Set it to 1.
Clicks hit the game through a buttonCheck UI.PointerOverUI before handling game clicks.
Audio: clip '...' is not in this buildNothing references the file; see what goes into a build.
failed to open as ogg, format errorThe .ogg holds Opus or FLAC audio. Re-encode it as Vorbis.
GEN002 Duplicate component idTwo JSON files share an id. Pick a free one.
IntelliSense doesn't know a componentSave so the editor compiles once, then reload the project in the IDE.
Editor slows down when spawning thousands of entitiesThe profiler is collecting. Close the Watchdog panel or set Preferences › Debug › Profiler to Off. Also check you are measuring a Release build.
Headless without Mesa (no OpenGL) warningThe server has no OpenGL. Everything except drawing still runs; install Mesa for rendering and Render timings.
Headless test run exits with code 2The game was exported with Strip Debugger / Profiler. Export again with it off.
hang: frame took ... stuck in Frame > Update > XSystem X did not return for 5 seconds (an endless loop?). The log line names it; add --hang-abort 30 so automated runs never block.
A start-up failed dialog, or exit code 1 before the first frameThe dialog and the log say why (window / OpenGL, the C# runtime, a shader). See When start-up fails.
LEAK: n block(s) allocated with new in this test case were never deletedThat unit test (or engine code it called) allocated without freeing. See Finding a leak.
engine tests (headless) fails with the C# script runtime did not startThat build folder has no CSharpRuntime/ next to the executable. Re-run CMake so the C# step builds into the same folder (CMakeLists.txt passes it to build_csharp), then run again.
export project (editor --build) failsThe step shows the editor’s Build FAILED reason, for example a start scene that doesn’t exist (set it in File › Build Settings) or a script compile error. Full output: test-results/<config>/export.log. Export the project once from the editor to see the same message there.
Engine tests: unknown component 'Transform' skipped, systems_run 0The game ran without the project’s compiled scripts: you used --no-export with a build folder no editor compiled into. Drop --no-export; see How the engine tests get your scripts.
GAM200_Editor --headless --build loads the start scene and never exportsThat editor was built before headless export existed. Rebuild it (the C# side compiles Editor/HeadlessBuild.cs), or run the export without --headless.
ci.py: no *.astge project foundPass the project with --project path/to/ProjectFolder, or set GAM_PROJECT. See ci.py.
Git shows GameScripts.csproj as changedSomeone opened the project with a Release editor, or from another folder layout. Discard the change; see GameScripts.csproj and git.
mutation ...: SURVIVED in ci.py --mutation-checkNo unit test notices that deliberate bug. Add a test for the behaviour.
mutation ...: `find` occurs 0 timesSomeone edited the line the mutation patches. Update its entry in Tests/unit/mutations.json.
[ci] missing tools: dotnetThe full build compiles the C# runtime: install the .NET SDK.
Unit tests fail to link: undefined reference to ...The ECS started calling a new engine function. Add a test double for it to Tests/unit/EngineSeams.cpp.
Reference

API reference

Every public member scripts can use, grouped by area. Signatures are taken from the engine source. Namespaces: Engine.Core for the runtime, GeneratedECS for generated components, Engine.Editor and Engine.UI for editor tools.

ECS

World

static classEngine.Core

Entity-level access usable from any system or callback. Entity ids are generation-checked handles (see Entities & handles).

IsAlive
bool IsAlive(ulong entity)

True while the entity exists. A handle to a destroyed entity stays false even if its slot is reused.

Has
bool Has(ulong entity, uint componentTypeId)

Whether the entity has the component. Prefer the generated X.Has(e).

CreateEntity
ulong CreateEntity()

A new empty entity (no components). If you add components to it right away, do it outside a system loop, or use Scenes.Spawn / Commands.

Destroy
void Destroy(ulong entity)

Destroys the entity. Deferred automatically while systems run. Children (via Parent) are destroyed with it.

RemoveComponent
void RemoveComponent(ulong entity, uint componentTypeId)

Removes a component (deferred while systems run). Prefer X.Remove(e).

GetRaw
void* GetRaw(ulong entity, uint componentTypeId)

Raw pointer to the component bytes, or null. Low level; the generated views wrap this.

IsDispatching
bool IsDispatching { get; }

True while the engine is running system callbacks (structural changes are deferred then).

Describe
string Describe(ulong entity)

"#12" or "#12 (dead)", for log messages.

IndexOf
uint IndexOf(ulong handle)

Slot index part of a handle.

GenerationOf
uint GenerationOf(ulong handle)

Generation part of a handle.

Handle
ulong Handle(ulong index)

Handle for a slot index (used by generated code; scripts rarely need it).

GetComponentArray
void* GetComponentArray(uint componentTypeId, out int count, out ulong* entities)

Dense array of every component of one type. Low level; use X.All().

Commands

structEngine.Core

Deferred structural changes, safe inside a system loop. Add a Commands cmd parameter to any lifecycle method. Nothing happens until the engine flushes the queue (between phases and at the end of the frame stage).

Destroy
void Destroy(ulong entity)

Queues destroying the entity.

Remove
void Remove(ulong entity, uint componentTypeId)

Queues removing a component: cmd.Remove(e, Enemy.TypeId).

Add
void Add<T>(ulong entity, uint componentTypeId, in T data) where T : unmanaged

Queues adding (or overwriting) a component from its raw storage struct, copied immediately: var d = Enemy_Defaults(); d.Health = 50; cmd.Add(e, Enemy.TypeId, in d); String and list fields can't be set this way (fill them next frame).

IsAlive
bool IsAlive(ulong entity)

Same as World.IsAlive.

<Component> (generated)

structGeneratedECS.GeneratedECSRegistry

Every component JSON becomes a struct with the JSON's name. It is a view that points into the engine's memory: setting a property writes straight into the entity's data, even on a copy in a local variable. Shown here for a component called Enemy.

TypeId
const uint TypeId

The component's id from its JSON. Used with Commands and World.

EntityId
ulong EntityId { get; }

The entity this component belongs to.

IsValid
bool IsValid { get; }

False for a default/empty view (e.g. after a failed TryGet).

Get
static Enemy Get(ulong entity)

The entity's component. Throws if it has none; use TryGet/Has when unsure.

TryGet
static bool TryGet(ulong entity, out Enemy component)

The component if the entity has it.

Has
static bool Has(ulong entity)

Whether the entity has the component.

All
static EnemyCollection All()

Every Enemy in the world, from anywhere (not only as a system parameter).

Create
static Enemy Create(ulong entity)

Adds the component now with its JSON defaults. Not inside a system loop (adding can move component memory); use AddDeferred there.

AddDeferred
static void AddDeferred(ulong entity)

Same as Create but queued: safe inside systems; the component exists after the next flush.

Remove
static void Remove(ulong entity)

Removes the component (deferred while systems run).

field (number/bool/Vec2/Color)
float Health { get; set; }

One property per JSON field, typed by the field type (see field types).

field (enum)
EnemyState State { get; set; }

Enum fields get a generated enum named <Component><Field> (or enumName).

field (asset)
string Icon { get; set; } / int IconId { get; set; }

Asset fields: the project path as a string, plus <Field>Id, the raw asset id (fast to compare; 0 = none).

field (Entity)
ulong Target { get; set; }

Entity handle (0 = none). Check World.IsAlive before using it.

Enemy_Defaults
static Enemy_RawStorage Enemy_Defaults()

The JSON defaults as a raw struct, for Commands.Add.

<Component>Collection (generated)

structGeneratedECS

What a system gets for its EnemyCollection parameter, and what Enemy.All() returns. Iterate with for + indexer or foreach.

Length
int Length { get; }

Number of components.

this[int]
Enemy this[int index] { get; }

The component in slot index. Writable through the view: var e = enemies[i]; e.Health -= 1;

EntityAt
ulong EntityAt(int index)

Entity id of slot index (same as enemies[i].EntityId).

Changed
bool Changed(int index)

True if slot index was added or modified since this system last ran. Always true unless the JSON sets "trackChanges": true.

IsTracked
bool IsTracked { get; }

Whether change tracking is on for this component.

GetEnumerator
Enumerator GetEnumerator()

foreach (var e in enemies). The loop variable is read-only: to write, copy it first (var en = e; en.Health = 0;) or use the indexer.

Query<A, B, C, D>

structEngine.Core

System parameter that iterates entities having all listed components (1 to 4). The first type drives the loop; the others are looked up per entity and entities missing any are skipped, so put the rarest first.

foreach
foreach (var row in q)

Query<A> yields A. 2-4 types yield a QueryRow: deconstruct with var (a, b) = row; or var (entity, a, b) = row;.

Length
int Length { get; }

Entities handed over by the engine, before the other components and [Without] are checked.

EntityAt
ulong EntityAt(int index)

Entity id of slot index.

Changed
bool Changed(int index)

Change detection for the first component; use with row.Index.

QueryRow<A, B, ...>

structEngine.Core

One entity of a multi-component query.

Entity
readonly ulong Entity

The entity.

Index
readonly int Index

Slot index (for q.Changed(row.Index)).

V0..V3
readonly A V0; readonly B V1; ...

The component views.

Deconstruct
void Deconstruct(out A a, out B b) / (out ulong entity, out A a, out B b)

Enables var (a, b) = row;.

System & event attributes

attributesEngine.Core

Attributes that turn classes and structs into systems, filters and events.

ECSSystem
[ECSSystem(uint systemId, Phase = SystemPhase.Update, Order = 0)]

Marks a static class as a system. The id must be unique (game systems 5000+, engine 900-999 reserved). Lower Order runs first inside a phase.

SystemPhase
enum SystemPhase { PreUpdate = 0, Update = 1, PostUpdate = 2 }

Which part of the frame a system runs in.

Without
[Without(params Type[] components)]

On a system class: its Query parameters skip entities that have any of these components. Not applied to Collection parameters.

ECSEvent
[ECSEvent(uint eventId)]

Marks a blittable struct as an event. Unique id; 0xFFFF0000 and above are reserved for the engine.

PersistentResource
[PersistentResource]

On a resource class: it survives Play/Stop instead of being reset.

Events & state

Events

static classEngine.Core

The shared C++/C# event bus. Events are queued and delivered in order when the engine dispatches (start of the frame and after LateUpdate), outside any system loop.

Publish
void Publish<T>(in T e) where T : unmanaged

Queues an event (copied immediately).

Subscribe
EventSubscription Subscribe<T>(Action<T> handler, bool persistent = false) where T : unmanaged

Listens for T. Cleared automatically when Play stops, so subscribe from OnStart. persistent: true survives Stop (editor tools).

Unsubscribe
void Unsubscribe(EventSubscription sub)

Stops listening (same as sub.Dispose()).

EventSubscription

structEngine.Core

Returned by Events.Subscribe.

IsValid
bool IsValid { get; }

False for a default value.

Dispose
void Dispose()

Unsubscribes.

Resources

static classEngine.Core

Global singletons that belong to no entity (score, settings, spawn tables). Plain classes, created on first use, reset when Play stops unless marked [PersistentResource].

Get
T Get<T>() where T : class, new()

The instance, created on first use: Resources.Get<GameState>().Score += 10;

TryGet
bool TryGet<T>(out T value) where T : class

The instance if it exists, without creating it.

Set
void Set<T>(T value) where T : class

Replaces or provides the instance (e.g. one loaded from a file).

Remove
bool Remove<T>() where T : class

Removes it.

Session

static classEngine.Core

Play-session plumbing.

FrameEnd
event Action<float> FrameEnd

End of every simulated frame, after LateUpdate, outside any system loop (safe for structural changes). Argument: delta time.

Ended
event Action Ended

The play session ended (editor Stop, scene teardown).

IsPlaying
bool IsPlaying { get; }

True while a play session runs (always true in an exported game).

FrameEndEvent

eventEngine.Core

[ECSEvent(0xFFFF0300)] published by the engine once per simulated frame.

fields
float DeltaTime, UnscaledDeltaTime

Scenes & app

Scenes

static classEngine.Core

Load scenes and spawn prototypes at runtime (exported game and editor Play mode). Names may be bare ("Level2"), with folders, with or without the extension.

Load
void Load(string scene)

Queues a scene change for the next frame boundary. "Level2" finds Scenes/Level2.scene.json.

Reload
void Reload()

Restarts the current scene.

Current
string Current { get; }

Loaded scene, e.g. "Scenes/Level1" ("" = none).

IsLoading
bool IsLoading { get; }

True during a load.

Find
ulong Find(string name)

First entity of the current scene with that editor name, or 0.

Spawn
void Spawn(string prototype, float? x = null, float? y = null, Action<ulong> done = null)

Spawns a .proto.json at the next frame boundary. Safe inside systems. done receives the new root entity (0 on failure).

Spawn (Vec2)
void Spawn(string prototype, Vec2 position, Action<ulong> done = null)
Instantiate
ulong Instantiate(string prototype, float? x = null, float? y = null)

Spawns immediately and returns the root. Never inside a system loop; use Spawn there.

Instantiate (Vec2)
ulong Instantiate(string prototype, Vec2 position)
DontDestroyOnLoad
void DontDestroyOnLoad(ulong entity)

Keeps the entity alive across scene changes. Children are not implied: call it for each.

Destroyable
void Destroyable(ulong entity)

Undoes DontDestroyOnLoad.

SkipInactive
static bool SkipInactive = true

Entities with Active = false in the editor are not created at runtime.

Loading
event Action<string> Loading

Right before the old scene is torn down.

Loaded
event Action<string> Loaded

After the new scene exists.

Hierarchy

static classEngine.Core

Parenting at runtime. Transform is world space; a child's world Transform is recomputed every frame from its parent and its Parent.Local* offsets.

SetParent
bool SetParent(ulong child, ulong parent, bool keepWorld = true)

Makes child a child of parent. keepWorld: the child stays where it is. Returns false for dead entities, missing Transforms or cycles. The first call on an entity adds a Parent component immediately: call it from OnStart/events, not inside a loop over that entity.

ClearParent
void ClearParent(ulong child)

Detaches; the child keeps its world transform.

TryGetParent
bool TryGetParent(ulong child, out ulong parent)

The parent if linked and alive.

Application

static classEngine.Core

The running game.

Quit
void Quit()

Exported game: closes it. Editor: stops Play mode. Raises Quitting and saves SaveData if AutoSave.

Quitting
event Action Quitting

Raised when Quit is called.

IsEditor
bool IsEditor { get; }

Running inside the editor.

IsExportedGame
bool IsExportedGame { get; }
IsMobile
bool IsMobile { get; }

Platform is Android.

Platform
string Platform { get; }

"Windows", "Linux", "Android", ...

ProductName
string ProductName { get; }

From Build Settings.

Company
string Company { get; }
Version
string Version { get; }
CommandLine
string[] CommandLine { get; }

The process command line (argv[0] first).

OpenURL
void OpenURL(string url)

Opens a web page or file with the OS (desktop).

Screen

static classEngine.Core

The game window. Ignored inside the editor; Android is always fullscreen.

Fullscreen
bool Fullscreen { get; set; }

Borderless fullscreen on/off.

ToggleFullscreen
void ToggleFullscreen()

What F11 / Alt+Enter do.

CursorVisible
bool CursorVisible { get; set; }

Show/hide the OS cursor.

Width / Height
int Width { get; } int Height { get; }

Game view size in pixels.

ProjectSettings

static classEngine.Core

The project manifest (.astge) written by the Build window.

Get
string Get(string key, string fallback = "")

Any manifest key.

ProductName / Company / StartScene
string ProductName { get; } string Company { get; } string StartScene { get; }

Time

Time

static classEngine.Core

Frame timing, valid anywhere. DeltaTime is scaled by TimeScale and is 0 while the editor is stopped or paused.

DeltaTime
float DeltaTime { get; }

Seconds this frame, scaled. Equals Update's dt.

UnscaledDeltaTime
float UnscaledDeltaTime { get; }

Real seconds this frame, ignoring TimeScale and pause.

FixedDeltaTime
float FixedDeltaTime { get; }

The FixedUpdate step (1/60 s).

TimeScale
float TimeScale { get; set; }

1 normal, 0.5 slow motion, 0 frozen. Affects Update, FixedUpdate rate and animation.

TotalTime
double TotalTime { get; }

Scaled seconds since the play session started.

UnscaledTotalTime
double UnscaledTotalTime { get; }

Real seconds since the engine started.

FrameCount
ulong FrameCount { get; }

Frames simulated this session.

FixedStepsThisFrame
int FixedStepsThisFrame { get; }

FixedUpdate steps run this frame (0..5).

Timers

static classEngine.Core

Delays and repeats without countdown fields. Game time unless unscaled: true. Callbacks run at the end of the frame, outside system loops. All timers are cancelled when Play stops.

After
TimerHandle After(float seconds, Action action, ulong owner = 0, bool unscaled = false)

Calls action once. With owner, it is skipped if that entity died first.

Every
TimerHandle Every(float interval, Action action, int count = -1, ulong owner = 0, bool unscaled = false, bool runNow = false)

Repeats (count times, -1 = until cancelled). runNow also fires once at the end of this frame.

DestroyAfter
TimerHandle DestroyAfter(ulong entity, float seconds)

Destroys the entity later.

Cancel
void Cancel(TimerHandle h)
CancelAll
void CancelAll(ulong owner)

Cancels every timer started with this owner.

IsActive
bool IsActive(TimerHandle h)
TimeLeft
float TimeLeft(TimerHandle h)

Seconds until it fires next.

Now
float Now { get; }

Scaled seconds since the session started (same clock as Cooldown).

Count
int Count { get; }

Active timers (debugging).

TimerHandle

structEngine.Core

Returned by Timers.After / Every.

IsActive
bool IsActive { get; }
Cancel
void Cancel()
None
static readonly TimerHandle None

Cooldown

structEngine.Core

Reusable cooldown on game time: if (cd.TryUse()) Fire();

ctor
Cooldown(float duration)
Duration
float Duration
TryUse
bool TryUse()

True (and restarts) if it was ready.

IsReady
bool IsReady { get; }
Remaining
float Remaining { get; }

Seconds until ready.

Progress
float Progress { get; }

0 right after use, 1 when ready (UI fill bars).

Trigger
void Trigger()

Starts the cooldown without checking.

Reset
void Reset()

Ready now.

Input

Input

static classEngine.Core

Polled input. Pressed/Released and deltas last one frame: read them in Update, not OnFixedUpdate. Coordinates are window pixels (the Game view in the editor), origin top-left, y down.

KeyDown
bool KeyDown(Key k) / KeyDown(int scancode)

Key is held.

KeyPressed
bool KeyPressed(Key k)

Went down this frame.

KeyReleased
bool KeyReleased(Key k)

Went up this frame.

KeyTyped
bool KeyTyped(Key k)

Pressed this frame including key repeat (text editing).

Axis
float Axis(Key negative, Key positive)

-1 / 0 / +1, e.g. Axis(Key.A, Key.D).

MoveVector
Vec2 MoveVector { get; }

WASD / arrows as a vector (x right, y up), length ≤ 1.

Ctrl / Shift / Alt
bool Ctrl { get; } bool Shift { get; } bool Alt { get; }

Either side held.

MouseX / MouseY
float MouseX { get; } float MouseY { get; }
MousePosition
Vec2 MousePosition { get; }

Screen pixels. World position: Camera.MouseWorld.

MouseDX / MouseDY
float MouseDX { get; } float MouseDY { get; }

Movement this frame.

WheelX / WheelY
float WheelX { get; } float WheelY { get; }

Scroll this frame.

MouseDown / MousePressed / MouseReleased
bool MouseDown(MouseButton b)

MouseButton.Left / Middle / Right / X1 / X2.

Pointer
PointerState Pointer { get; }

Left mouse button on desktop, first finger on touch. Write gameplay against this and it works on both.

TouchCount
int TouchCount { get; }

Valid entries for GetTouch (includes fingers lifted this frame).

GetTouch
TouchPoint GetTouch(int index)
TextThisFrame
string TextThisFrame { get; }

Text typed this frame (IME / on-screen keyboard aware).

KeyEventsThisFrame
ReadOnlySpan<int> KeyEventsThisFrame { get; }

Scancodes pressed this frame including repeat.

SetTextInput
void SetTextInput(bool on)

OS text input / Android keyboard on or off. UI input fields do this for you.

WindowWidth / WindowHeight
int WindowWidth { get; } int WindowHeight { get; }
Frame
uint Frame { get; }

Input frame counter.

PointerState

structEngine.Core

Returned by Input.Pointer.

fields
float X, Y, DX, DY; bool Down, Pressed, Released, IsTouch
Position
Vec2 Position { get; }

TouchPoint

structEngine.Core

One finger.

fields
ulong Id; float X, Y, DX, DY, Pressure

Window pixels; DX/DY = movement this frame.

IsDown / WasPressed / WasReleased
bool IsDown { get; } bool WasPressed { get; } bool WasReleased { get; }

Key

enumEngine.Core

SDL3 scancodes for the common keys. Any other key: Input.KeyDown((int)scancode).

values
A..Z, Num0..Num9, F1..F12, Return, Escape, Backspace, Tab, Space, Minus, Equals, LeftBracket, RightBracket, Backslash, Semicolon, Apostrophe, Grave, Comma, Period, Slash, Insert, Home, PageUp, Delete, End, PageDown, Right, Left, Down, Up, KeypadEnter, LeftCtrl, LeftShift, LeftAlt, RightCtrl, RightShift, RightAlt, Back

Key.Back is the Android back button / gesture.

Physics

Physics

static classEngine.Core

2D boxes and circles, rigidbodies, contacts, raycasts and overlaps. Bodies are simulated at 60 Hz in PostUpdate. Rotation is not simulated. Handlers run at the end of the frame (safe to destroy/spawn).

Gravity
static Vec2 Gravity = (0, -980)

World units/s². Multiplied by each body's GravityScale (default 0 = top-down).

DebugDraw
static bool DebugDraw

Outline every collider (and contacts).

Iterations
static int Iterations = 4

Solver iterations per step.

OnCollisionEnter / Stay / Exit
void OnCollisionEnter(ulong entity, Action<Collision> h)

Per-entity contact callbacks. Cleared when Play stops.

OnTriggerEnter / Stay / Exit
void OnTriggerEnter(ulong entity, Action<Collision> h)

Same for triggers.

ClearHandlers
void ClearHandlers(ulong entity)
CollisionEnter ... TriggerExit
event Action<Collision> CollisionEnter, CollisionStay, CollisionExit, TriggerEnter, TriggerStay, TriggerExit

Every contact in the game.

Raycast
bool Raycast(Vec2 origin, Vec2 direction, float maxDistance, out RaycastHit hit, uint layerMask = AllLayers, bool includeTriggers = false, ulong ignore = 0)

Closest hit.

Raycast (value)
RaycastHit Raycast(Vec2 origin, Vec2 direction, float maxDistance = 100000f, uint layerMask = AllLayers)

Check hit.Hit.

RaycastAll
List<RaycastHit> RaycastAll(Vec2 origin, Vec2 direction, float maxDistance, uint layerMask = AllLayers, bool includeTriggers = false)

Nearest first.

OverlapCircle
List<ulong> OverlapCircle(Vec2 center, float radius, uint layerMask = AllLayers, bool includeTriggers = true)
OverlapBox
List<ulong> OverlapBox(Vec2 center, Vec2 size, float angle = 0f, uint layerMask = AllLayers, bool includeTriggers = true)

size = full width/height.

OverlapPoint
List<ulong> OverlapPoint(Vec2 point, uint layerMask = AllLayers, bool includeTriggers = true)
IsTouching
bool IsTouching(ulong entity, uint layerMask = AllLayers)
AreTouching
bool AreTouching(ulong a, ulong b)
AddForce
void AddForce(ulong entity, Vec2 force)

Dynamic bodies, over one fixed step.

AddImpulse
void AddImpulse(ulong entity, Vec2 impulse)

Instant velocity change / mass.

SetVelocity / GetVelocity
void SetVelocity(ulong entity, Vec2 v) / Vec2 GetVelocity(ulong entity)
SetLayerCollision
void SetLayerCollision(int a, int b, bool collide)

Layers 0..31.

GetLayerCollision
bool GetLayerCollision(int a, int b)
NameLayer / Layer
void NameLayer(int layer, string name) / int Layer(string name)
Mask
uint Mask(params int[] layers) / uint Mask(params string[] names)

Layer mask for queries.

AllLayers
const uint AllLayers

Collision

structEngine.Core

One contact, seen from Entity.

Entity
ulong Entity

The entity whose handler is called.

Other
ulong Other

What it touched.

Normal
Vec2 Normal

Pushes Entity out of Other (unit length).

Point
Vec2 Point

Approximate contact point.

Depth
float Depth

Overlap when the contact began.

IsTrigger
bool IsTrigger

Either collider is a trigger.

RaycastHit

structEngine.Core

Result of a raycast.

fields
ulong Entity; Vec2 Point, Normal; float Distance
Hit
bool Hit { get; }

Entity != 0.

Tags

static classEngine.Core

Lookups on the Tag component.

Has
bool Has(ulong entity, string tag)
Get
string Get(ulong entity)
Set
void Set(ulong entity, string tag)

Adds a Tag component if needed (not inside a system loop for entities without one).

Find
ulong Find(string tag)

First entity with the tag, or 0.

FindAll
List<ulong> FindAll(string tag)

Camera, animation & audio

Camera

static classEngine.Core

The game camera. With a Camera2D entity in the scene these write into it; without one they control a default camera. 1 world unit = 1 pixel at Zoom 1.

Follow
void Follow(ulong target, float speed = 0f, Vec2 offset = default)

speed 0 = locked on; 4..10 feels smooth.

StopFollow
void StopFollow()
SnapToTarget
void SnapToTarget()

Jump to the target now (after a teleport or load).

FollowTarget
ulong FollowTarget { get; }
Position
Vec2 Position { get; set; }
Zoom
float Zoom { get; set; }

1 default, 2 twice as close (0.01..100).

Rotation
float Rotation { get; set; }

Degrees, counter-clockwise.

BackgroundColor
Color BackgroundColor { get; set; }
SetBounds / ClearBounds
void SetBounds(Vec2 min, Vec2 max) / void ClearBounds()

Keep the centre inside a rectangle.

Shake
void Shake(float intensity, float duration, float frequency = 25f)

intensity in world units, real-time seconds.

StopShake
void StopShake()
ScreenToWorld / WorldToScreen
Vec2 ScreenToWorld(Vec2 screen) / Vec2 WorldToScreen(Vec2 world)
MouseWorld
Vec2 MouseWorld { get; }

Mouse or finger in world coordinates.

ScreenSize / ViewSize
Vec2 ScreenSize { get; } Vec2 ViewSize { get; }

Pixels / visible world area.

IsVisible
bool IsVisible(Vec2 world, float margin = 0f)
Entity
ulong Entity { get; }

The primary Camera2D entity (0 = none).

Animator

static (on the component)GeneratedECS.GeneratedECSRegistry

Plays .anim.json clips. Clip names may be bare ("Run") or paths.

Play
void Play(ulong entity, string clip, bool restart = false, float speed = float.NaN)

Adds an Animator if missing. Already playing that clip: nothing happens unless restart.

Stop / Pause / Resume
void Stop(ulong e) void Pause(ulong e) void Resume(ulong e)
SetSpeed
void SetSpeed(ulong entity, float speed)
IsPlaying
bool IsPlaying(ulong entity) / bool IsPlaying(ulong entity, string clip)
CurrentClip
string CurrentClip(ulong entity)

Path, "" = none.

Length
float Length(string clip)

Seconds at speed 1.

FindClip
int FindClip(string clip)

Asset id (0 if missing).

OnFinished
void OnFinished(ulong entity, Action<string> handler)

Each time a non-looping clip on this entity ends.

Finished
event Action<ulong, string> Finished

Any entity's non-looping clip ended.

Audio

static classEngine.Core

Sound through FMOD. Each sound is an int handle to a native AudioComponent. Handles are freed automatically when Play stops.

Create
int Create(string clip, AudioGroup group = AudioGroup.SFX, float volume = 1f, float pitch = 1f)

Returns a handle (0 = audio unavailable). Loads the clip if needed.

Create (asset id)
int Create(int clipAssetId, AudioGroup group = AudioGroup.SFX, float volume = 1f, float pitch = 1f)

From an Audio component field: Audio.Create(c.MusicId, AudioGroup.BGM).

Register
bool Register(string clip)

Preloads a clip (avoids a hitch on first play).

Play
void Play(int handle)

Queues the sound; starts at the end of the frame. Ignored while already queued.

PlayFirst
void PlayFirst(int handle)

Queued at the front.

Stop
void Stop(int handle)

Only while playing.

Pause / Resume
void Pause(int handle) / void Resume(int handle)
Release
void Release(int handle)

Stops it and frees the handle.

SetClip
void SetClip(int handle, string clip)

Next Play uses the new clip.

SetGroup / SetVolume / SetPitch
void SetGroup(int h, AudioGroup g) void SetVolume(int h, float v) void SetPitch(int h, float p)

Applied on the next Play.

GetState
AudioState GetState(int handle)

Valid, IsPlaying, IsPaused, IsPending, ChannelIndex.

AudioGroup / AudioState

enum / structEngine.Core
AudioGroup
enum AudioGroup { BGM = 0, SFX = 1 }
AudioState
struct AudioState { bool Valid, IsPlaying, IsPaused, IsPending; int ChannelIndex; }

UI

UI

static classEngine.Core

Script side of the Canvas UI components.

OnClick
void OnClick(ulong button, Action handler) / void OnClick(string actionName, Action handler)

By entity, or by the button's Action field.

OnValueChanged
void OnValueChanged(ulong element, Action<float> handler)

Slider value, toggle 0/1, input field length.

OnSubmit
void OnSubmit(ulong inputField, Action<string> handler)

Enter pressed in an input field.

Event
event Action<UIEvent> Event

Every UI event (click, value, submit, focus, hover).

Find
ulong Find(string name)

Same as Scenes.Find.

SetText / GetText
void SetText(ulong e, string text) / string GetText(ulong e)

UIText, UIInputField or UIToggle label.

SetVisible / IsVisible
void SetVisible(ulong e, bool visible) / bool IsVisible(ulong e)
SetInteractable
void SetInteractable(ulong e, bool on)
Focus
void Focus(ulong inputField)

0 = none.

Focused
ulong Focused { get; }

The focused input field.

PointerOverUI
bool PointerOverUI { get; }

Pointer is over an interactable element: ignore game clicks.

TryGetScreenRect
bool TryGetScreenRect(ulong e, out float x, out float y, out float w, out float h)

Where it was drawn last frame.

UIEvent / UIEventKind

event / enumEngine.Core

[ECSEvent(0xFFFF0500)]

UIEvent
struct UIEvent { ulong Entity; int Kind; float Value; UIEventKind EventKind { get; } }
UIEventKind
enum UIEventKind { Click, ValueChanged, Submit, FocusGained, FocusLost, PointerEnter, PointerExit }

UIDraw

static classEngine.Core

Immediate drawing in screen pixels (origin top-left, y down) on top of the game, from any system. Shown until the next frame.

Rect
void Rect(float x, float y, float w, float h, Color color)
Outline
void Outline(float x, float y, float w, float h, Color color, float thickness = 1f)
Image
void Image(string texture, float x, float y, float w, float h, Color tint, float u0 = 0, float v0 = 0, float u1 = 1, float v1 = 1)

Also with an int textureId.

ImageFrame
void ImageFrame(int textureId, int frame, float x, float y, float w, float h, Color tint, ...)

One frame of a GIF / APNG.

Sliced
void Sliced(int textureId, float x, float y, float w, float h, Color tint, float borderScale = 1f, int frame = 0, float fill = 1f)

9-slice using the texture's .meta border.

Text
void Text(string text, float x, float y, float size, Color color, string font = null)

One line at top-left.

Text (box)
void Text(string text, float x, float y, float w, float h, float size, Color color, TextAlign align, TextVAlign valign, bool wrap = false, string font = null)

Aligned in a box, optionally wrapped.

MeasureText
Vec2 MeasureText(string text, float size, float wrapWidth = 0f, string font = null)
PushClip / PopClip
void PushClip(float x, float y, float w, float h) / void PopClip()

Masks / scroll views.

BeginBehindSprites / EndBehindSprites
void BeginBehindSprites() / void EndBehindSprites()

Draw behind the world's sprites (backgrounds).

ScreenWidth / ScreenHeight
float ScreenWidth { get; } float ScreenHeight { get; }
TextAlign / TextVAlign
enum TextAlign { Left, Center, Right } enum TextVAlign { Top, Middle, Bottom }

Assets & files

Assets

static classEngine.Core

Project files at runtime, the same in the editor, a desktop export and on Android. Paths are relative to the project folder.

ReadText
string ReadText(string path)

UTF-8 text, or null.

ReadBytes
byte[] ReadBytes(string path)

Raw bytes, or null.

Exists
bool Exists(string path)
Find
string Find(string name)

Bare name to path: "Run" → "Animations/Run.anim.json". "" if unknown.

IdOf / KeyOf
int IdOf(string pathOrName) / string KeyOf(int id)

Asset ids are per run: save the path, never the id.

FullPath
string FullPath(string path)

File-system path the engine would open (desktop).

TextureSize
Vec2 TextureSize(string path)

Pixels; (0,0) if it can't load.

TextureGrid
(int cols, int rows) TextureGrid(string texture)

Sprite-sheet grid from the .meta.

TextureBorder
(int left, int top, int right, int bottom) TextureBorder(int textureId)

9-slice border.

TextureFrames / TextureFrameAt
int[] TextureFrames(int id) / int TextureFrameAt(int id, double seconds)

GIF / APNG frame delays.

Reload / Refresh
bool Reload(string path) / void Refresh()

Editor: reload a changed file / rebuild the name index.

Paths

static classEngine.Core

Well-known folders.

StreamingAssets / Resources
const string StreamingAssets = "StreamingAssets"; const string Resources = "Resources";

Always exported.

ProjectRoot
string ProjectRoot { get; }

"" on Android.

IsExportedGame
bool IsExportedGame { get; }
PrefPath
string PrefPath(string company, string product)

Writable per-user folder.

SaveData

static classEngine.Core

Player progress as a key/value JSON file per save slot. Editor: <Project>/Library/SaveData/ (never your real saves). Game: the OS per-user folder.

SetInt / SetFloat / SetBool / SetString
void SetInt(string key, int v) ...
GetInt / GetFloat / GetBool / GetString
int GetInt(string key, int fallback = 0) ...
SetObject / GetObject
void SetObject<T>(string key, T value) / T GetObject<T>(string key, T fallback = default)

Any plain class (public fields/properties).

HasKey / DeleteKey / DeleteAll / Keys
bool HasKey(string key) ...
Save
bool Save()

Writes the slot atomically. Also done when Play stops and on Quit (AutoSave).

Reload
void Reload()

Discards unsaved changes.

Slot
string Slot { get; set; }

Switching saves the old slot and loads the new one (default "save").

ListSlots / DeleteSlot
string[] ListSlots() / void DeleteSlot(string slot)
AutoSave
static bool AutoSave = true
Folder
string Folder { get; }

Debugging

Debug

static classEngine.Core

Logging. Log / LogWarning / Assert are compiled out of exports with Strip Debug Logs on. LogError / LogException always stay and go to log.txt in the save folder. For timings use Profiler.

Log
void Log(object message)

Console; double-click jumps to the line.

LogWarning
void LogWarning(object message)
LogError
void LogError(object message)

With stack trace. Kept in release.

LogException
void LogException(Exception ex, string context = null)

Links to the line that threw.

Assert
void Assert(bool condition, string message = null)
DrawLine
void DrawLine(Vec2 a, Vec2 b, Color color, float duration = 0f)

Alias of DebugDraw.Line.

Enabled
static bool Enabled = true

Runtime master switch.

MinLevel
static LogType MinLevel = LogType.Info
MessageLogged
event Action<LogEntryInfo> MessageLogged

Every message (for an in-game console).

DebugDraw

static classEngine.Core

World-space debug shapes drawn over the game. Compiled out of release exports. duration 0 = this frame only.

Line
void Line(Vec2 a, Vec2 b, Color color, float duration = 0f)
Ray
void Ray(Vec2 origin, Vec2 direction, Color color, float duration = 0f)
Arrow
void Arrow(Vec2 from, Vec2 to, Color color, float headSize = 10f, float duration = 0f)
Box
void Box(Vec2 center, Vec2 size, Color color, float angle = 0f, float duration = 0f)
Rect
void Rect(Vec2 min, Vec2 max, Color color, float duration = 0f)
Circle
void Circle(Vec2 center, float radius, Color color, int segments = 32, float duration = 0f)
Cross
void Cross(Vec2 p, float size, Color color, float duration = 0f)
Path
void Path(IReadOnlyList<Vec2> points, Color color, bool closed = false, float duration = 0f)
Enabled
static bool Enabled = true

ScriptErrors

static classEngine.Core

Every system callback, event handler, timer and UI callback is wrapped: an exception is logged with its file and line and the game keeps running.

ErrorRaised
event Action<Exception> ErrorRaised
Count
int Count { get; }

Errors this run.

Profiler

static classEngine.Core

Scoped timers, counters and markers for scripts. They show up in the Watchdog panel, the terminal summary of headless runs, the report and the Chrome trace. Systems are timed automatically; use this for your own code inside them.

Scope
Zone Scope(string name)

Times the code until the returned value is disposed: using (Profiler.Scope("Pathfinding")) { ... }. Nested scopes nest in the trace.

Begin / End
void Begin(string name) / void End()

Same without using; every Begin needs an End on the same frame.

Counter
void Counter(string name, double value)

A value per frame (last write wins): history, min, max and average are kept and graphed.

Mark
void Mark(string text)

An instant event in the Events tab and the trace.

Stat
double Stat(string name)

Any profiler value by name, NaN if unknown: fps_avg, frame_ms_p99, rss_mb, entities, system_ms:<System>, zone_ms:<Scope>, your counters (suffix .avg / .min / .max). Same names as test scripts' expect.

DebuggerEnabled
bool DebuggerEnabled { get; }

False in an export built with Strip Debugger / Profiler; every Profiler call then returns at once.

Zone
readonly struct Zone : IDisposable

Returned by Scope.

DebugConsole

static classEngine.Core

Debug console commands shared by the Watchdog Console tab, the terminal of headless runs, --exec and test scripts. See Debug console commands.

Register
void Register(string name, string usage, string help, Func<string[], string> fn)

Adds a command. args[0] is the name; return the text to show, throw to report a failure. Removed automatically when game scripts reload.

Execute
string Execute(string line) / bool Execute(string line, out string result)

Runs any command (C++ built-ins included); several can be joined with ;.

List
List<(string name, string usage, string help, bool managed)> List()

Every command.

Tokenize
string[] Tokenize(string line)

Splits on spaces, honouring quotes.

EngineTest / IEngineTest / TestAssert

test APIEngine.Core

C# test cases in a class library passed to --test MyTests.dll. See Engine test cases.

[EngineTest]
[EngineTest(Name = "...")]

On a public static void method: runs once; an exception or failed assert fails it.

IEngineTest
interface IEngineTest { void Start(); bool Update(float dt); }

A test that runs across frames; Update returns true when finished.

TestAssert.True / False
void True(bool condition, string message = "expected true")
TestAssert.Equal / Near
void Equal<T>(T expected, T actual, string what = "value") / void Near(double expected, double actual, double tolerance, string what = "value")
TestAssert.Fail
void Fail(string message)
TestAssert.Stat
double Stat(string name)

Same as Profiler.Stat.

DebuggerNative

static classEngine.Core

Read side of the native debugger, used by the Watchdog panel. Editor tools can use it for their own displays. Struct layouts mirror the engine's (DebugNatives.cpp).

Available
bool Available { get; }

False when the engine has no debugger natives.

Frame
FrameInfo Frame()

Frame number, frame / work ms, fps, avg / p99 / max ms, CPU %, hitch / hang / error / warning counts, paused, headless, rendering (1 drawing, 0 --no-render, -1 no OpenGL), tracing, table sizes.

SystemAt / ZoneAt / CounterAt / EventAt / LeakAt / TestAt
bool SystemAt(int i, out SystemInfo s) ...

Row i of each table (false past the end).

SystemHistory / ZoneHistory / CounterHistory
int SystemHistory(int i, float[] dst) ...

Per-frame history, oldest first; returns the count.

FindCounter
int FindCounter(string name)

-1 if unknown.

StartTrace / StopTrace / TracePath
bool StartTrace(string path) / void StopTrace() / string TracePath()

Chrome trace for part of a run.

WriteReport
bool WriteReport(string path)

Full text report.

Reset / SetPaused / Step
void Reset() / void SetPaused(bool on) / void Step(int frames)

Clear statistics; the debugger's pause gate.

ProfilerEnabled
bool ProfilerEnabled { get; set; }

Data collection on/off (off = frame clock only). The editor's Profiler preference sets this.

ProfilingRequested
bool ProfilingRequested { get; }

The command line asked for profiling (--profile, --trace, --report, --test).

HangTimeout
double HangTimeout { get; set; }

Seconds before a frame counts as a hang (0 = off).

Math

Vec2

structEngine.Core

2D vector (world units, y up). Also a component field type.

fields
float X, Y
constants
Zero, One, Up, Down, Left, Right
Length / LengthSquared / Normalized / Angle / Perpendicular
float Length { get; } ...

Angle in degrees (0 = right, 90 = up).

operators
+ - * (float or Vec2) / == !=

Implicit from (x, y) tuples and System.Numerics.Vector2.

Dot / Cross / Distance / DistanceSquared
static float Dot(Vec2 a, Vec2 b) ...
Lerp / LerpUnclamped / MoveTowards
static Vec2 Lerp(Vec2 a, Vec2 b, float t) ...
Min / Max / ClampLength
static Vec2 ClampLength(Vec2 v, float max) ...
FromAngle / Rotate / SignedAngle / Reflect
static Vec2 FromAngle(float degrees) ...

Degrees, counter-clockwise.

SmoothDamp
static Vec2 SmoothDamp(Vec2 current, Vec2 target, ref Vec2 velocity, float smoothTime, float dt)

Color

structEngine.Core

RGBA, 0..1 per channel. Also a component field type.

ctor
Color(float r, float g, float b, float a = 1f)
named
White, Black, Clear, Gray, Red, Green, Blue, Yellow, Cyan, Magenta, Orange
Hex / TryParse / ToHex
static Color Hex(string "#RRGGBB(AA)") ...
HSV
static Color HSV(float h, float s, float v, float a = 1f)

All 0..1.

Lerp / WithAlpha / operator *
static Color Lerp(Color a, Color b, float t) ...

Mathf

static classEngine.Core

Float maths with Unity-style names.

constants
PI, Deg2Rad, Rad2Deg, Epsilon
Clamp / Clamp01 / Lerp / LerpUnclamped / InverseLerp / Remap
static float Remap(float v, float inMin, float inMax, float outMin, float outMax) ...
LerpAngle / DeltaAngle / MoveTowards / MoveTowardsAngle
static float DeltaAngle(float current, float target) ...

Degrees.

SmoothStep / SmoothDamp / Damp
static float Damp(float current, float target, float rate, float dt)

Damp is frame-rate independent.

Repeat / PingPong / Approximately / Sign
Abs Min Max Sqrt Pow Sin Cos Atan2 Floor Ceil Round FloorToInt CeilToInt RoundToInt

Random

static classEngine.Core

One shared generator; call Seed for reproducible runs.

Range (float)
float Range(float min, float max)

Both inclusive.

Range (int)
int Range(int min, int max)

Max exclusive: Range(1, 7) = 1..6.

Value / Bool / Sign
float Value { get; } ...
Chance
bool Chance(float p)
Pick / Shuffle
T Pick<T>(params T[] items) / void Shuffle<T>(IList<T> list)
InsideUnitCircle / Direction / InRect / ColorHSV
Vec2 InsideUnitCircle { get; } ...
Seed
void Seed(int seed)

Editor tools

EditorWindow

abstract classEngine.Editor

Base class for [EditorWindow("Menu/Path")] tools in the project's Editor/ folder.

OnGUI
abstract void OnGUI()

Draw with Engine.UI.ImGui and InspectorWidgets.

OnOpen / OnClose
virtual void OnOpen() / virtual void OnClose()
Title / InitialSize / IsOpen
string Title { get; set; } ...
Close / Show
void Close() / static T Show<T>()

ComponentInspector

abstract classEngine.Editor

Base class for [CustomInspector(typeof(X))].

OnInspectorGUI
abstract bool OnInspectorGUI()

Return true when you changed a value (undo + dirty).

DrawDefault / Field
bool DrawDefault() / bool Field(string name)

Standard widgets.

Get*/Set*
GetFloat/SetFloat, GetInt/SetInt, GetBool/SetBool, GetString/SetString, GetVec2/SetVec2, GetColor/SetColor, GetAsset/SetAsset, GetEntity/SetEntity

By field name.

Node / Document / Meta / Store
EntityNode Node ...

The inspected entity and component.

MenuItem / CustomInspector

attributesEngine.Editor
MenuItem
[MenuItem(string path, Shortcut = "", Order = 0)]

On a static method with no parameters.

CustomInspector
[CustomInspector(Type componentType)] / [CustomInspector(string componentName)]
EditorWindow
[EditorWindow(string menuPath)]

Selection

static classEngine.Editor

Editor selection and scene helpers.

Active
EntityNode Active { get; }
Select
void Select(EntityNode n)
Find
EntityNode Find(Func<EntityNode, bool> match)
All
IEnumerable<EntityNode> All { get; }
ActiveAsset
string ActiveAsset { get; }

Selected file in the Project panel.

CreateEntity
EntityNode CreateEntity(string name, EntityNode parent = null)

Undoable.

MarkDirty
void MarkDirty()

InspectorWidgets / EditorConsole

static classesEngine.Editor
DrawFields / DrawField
bool DrawFields(ComponentMeta m, IFieldStore s, EditorDocument doc)
AssetPicker
bool AssetPicker(string id, string assetType, ref string rel)

Searchable asset picker with thumbnails.

EntityField
bool EntityField(string id, EditorDocument doc, ref EntityNode target)
EditorConsole
EditorConsole.Info(string) .Warn(string) .Error(string)

Write to the Console.

Reference

Built-in components

Fields of the engine's own components, as defined in EngineComponents/*.json. Use them from C# exactly like your own: Transform.Get(e).PosX, Sprite.TryGet(e, out var s).

Transform

id 100Core
FieldTypeDefaultNotes
PosXfloat0World position (units)
PosYfloat0
Rotationfloat0Degrees, counter-clockwise
ScaleXfloat11 = 100 world units
ScaleYfloat1

Sprite

id 101Core
FieldTypeDefaultNotes
TextureTexture–Image to draw. Empty = the engine's missing-texture image.
ColorColor#FFFFFFFFTint multiplied with the texture (alpha = transparency)
FlipXboolfalse
FlipYboolfalse
Frameint-1Sprite-sheet cell (-1 = whole image). Columns/rows come from the image .meta.
Orderint0Draw order: higher draws on top

Parent

id 102Core
FieldTypeDefaultNotes
Entityulong0Parent entity (read-only; set by dragging in the Hierarchy)
Linkedint0Hidden
LocalXfloat0Offset relative to the parent
LocalYfloat0
LocalRotationfloat0
LocalScaleXfloat1
LocalScaleYfloat1

Camera2D

id 103Core
FieldTypeDefaultNotes
Zoomfloat1Range 0.05..20. 2 = everything twice as big
PrimarybooltrueThe camera the game renders with (first primary one wins)
BackgroundColorColor#1E1E26FF
FollowEntity0Entity to follow
FollowSpeedfloat00 = snap; higher = tighter
OffsetVec20, 0Added to the followed position
UseBoundsboolfalse
BoundsMinVec2-1000, -1000
BoundsMaxVec21000, 1000

Animator

id 104Core
FieldTypeDefaultNotes
ClipAnimation–Clip to play (.anim.json). Code: Animator.Play(entity, "Run")
Speedfloat1Range 0..5
Timefloat0Read-only: seconds into the clip
Frameint0Read-only: current frame
Playingbooltrue

Collider

id 110Physics
FieldTypeDefaultNotes
Shapeenum ColliderShapeBoxBox, Circle
SizeVec21, 1Box size in units of the Transform scale (1, 1 = the sprite quad)
Radiusfloat0.5Circle radius relative to the Transform scale (0.5 = fits the quad)
OffsetVec20, 0Centre offset in world units
IsTriggerboolfalseReports overlaps but never pushes
Layerint0Physics layer 0..31

Rigidbody

id 111Physics
FieldTypeDefaultNotes
Typeenum BodyTypeDynamicDynamic: velocity, gravity and collisions. Kinematic: velocity only, pushes others. Static: never moves.
VelocityVec20, 0World units per second
GravityScalefloat00 = top-down. Multiplies Physics.Gravity
Massfloat1Range 0.01..1000
Dragfloat0Velocity damping per second (0..20)
Bouncinessfloat00..1
FreezeRotationbooltrueHidden

Tag

id 112Physics
FieldTypeDefaultNotes
Valuestring–Find with Tags.Find("Player") / Tags.Has(e, "Enemy")

Canvas

id 120UI
FieldTypeDefaultNotes
ReferenceWidthfloat1280UI is laid out for this resolution and scaled to the window
ReferenceHeightfloat720
Matchfloat0.50 = scale with width, 1 = with height
SortOrderint0
RenderModeenum CanvasRenderModeScreenSpaceScreenSpace or WorldSpace (placed at this entity's Transform)
WorldScalefloat1WorldSpace only: world units per reference pixel
Layerenum CanvasLayerAboveSpritesAboveSprites (HUD) or BehindSprites (backgrounds)

UIRect

id 121UI
FieldTypeDefaultNotes
AnchorMinVec20.5, 0.5Lower-left anchor in the parent (0,0 bottom-left, 1,1 top-right)
AnchorMaxVec20.5, 0.5Upper-right anchor. Different from AnchorMin = stretch
PivotVec20.5, 0.5
PositionVec20, 0Offset of the pivot from the anchors (reference pixels, y up)
SizeVec2160, 40Reference pixels (added to the anchor span when stretching)
Visiblebooltrue

UIImage

id 122UI
FieldTypeDefaultNotes
TextureTexture–Empty = plain colour rectangle
ColorColor#FFFFFFFF
Fillfloat1Horizontal fill 0..1 (health bars)
Frameint-1Sprite-sheet cell (-1 = whole image)
AnimatebooltruePlay animated GIF / APNG
Speedfloat1GIF playback speed

UIText

id 123UI
FieldTypeDefaultNotes
TextstringText
FontFont–Empty = engine default font
FontSizefloat244..200
ColorColor#FFFFFFFF
Alignenum UITextAlignCenterLeft, Center, Right
VAlignenum UITextVAlignMiddleTop, Middle, Bottom
Wrapboolfalse

UIButton

id 124UI
FieldTypeDefaultNotes
Interactablebooltrue
NormalColor#3A3F4BFF
HoverColor#4C5466FF
PressedColor#2A2E37FF
DisabledColor#2A2E3780
Actionstring–Name sent with the click: UI.OnClick("Play", ...)
NormalImageTexture–Optional 9-slice image; brightened / darkened for hover / press
HoverImageTexture–
PressedImageTexture–
DisabledImageTexture–

UIInputField

id 125UI
FieldTypeDefaultNotes
Textstring–
PlaceholderstringEnter text...
FontSizefloat22
TextColorColor#FFFFFFFF
BackgroundColor#1B1D23FF
MaxLengthint64
Passwordboolfalse
Interactablebooltrue

UIToggle

id 126UI
FieldTypeDefaultNotes
IsOnboolfalse
LabelstringToggle
FontSizefloat22
ColorColor#FFFFFFFF
Interactablebooltrue
BoxImageTexture–Optional 9-slice box
CheckImageTexture–Optional tick

UISlider

id 127UI
FieldTypeDefaultNotes
Valuefloat0.5
Minfloat0
Maxfloat1
WholeNumbersboolfalse
TrackColor#1B1D23FF
FillColor#4C8BF5FF
HandleColor#E6E6E6FF
Interactablebooltrue
TrackImageTexture–
FillImageTexture–
HandleImageTexture–