Skip to content

Core API

GodotECS.Core — entities, components, type registry, chunks, entity manager, queries, command buffers, worlds, systems. Source: addons/godotecs/src/core/.

Component markers

csharp
namespace GodotECS.Core
{
    public interface IComponentData { }
    public interface IBufferElementData { }
    public interface ISharedComponent { }
    public interface IEnableableComponent { }
    public interface ISingleton { }
    public interface IBlobAsset { }
}

Empty classifier interfaces. DisabledTag shows the enableable pattern: struct DisabledTag : IComponentData, IEnableableComponent — addable via AddComponent, switchable via SetEnable without an archetype move.

Entity

csharp
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
public readonly struct Entity : System.IEquatable<Entity>
{
    public readonly int Index;
    public readonly int Version;
    public static readonly Entity Null = new Entity(-1, 0);
    public Entity(int index, int version) { Index = index; Version = version; }
    public bool IsNull => Index < 0;
    public bool Equals(Entity other) => Index == other.Index && Version == other.Version;
    public override bool Equals(object obj) => obj is Entity e && Equals(e);
    public override int GetHashCode() => System.HashCode.Combine(Index, Version);
    public static bool operator ==(Entity a, Entity b) => a.Equals(b);
    public static bool operator !=(Entity a, Entity b) => !a.Equals(b);
    public override string ToString() => $"Entity({Index}:{Version})";
}

Unmanaged struct (index + version), copyable and storable inside components. Null has Index = -1. Version is never 0 for live entities; destroying bumps the version so stale handles fail IsAlive.

Standard components

csharp
public struct LocalTransform : IComponentData
{
    public Godot.Vector3 Pos;
    public Godot.Quaternion Rot;
    public float Scale;
    public static LocalTransform Identity => new LocalTransform
    {
        Pos = Godot.Vector3.Zero,
        Rot = Godot.Quaternion.Identity,
        Scale = 1f
    };
    public Godot.Transform3D ToTransform3D() => new Godot.Transform3D(new Godot.Basis(Rot, Scale), Pos);
}
public struct PrevTransform : IComponentData
{
    public Godot.Vector3 Pos;
    public Godot.Quaternion Rot;
    public float Scale;
}
public struct Velocity : IComponentData { public Godot.Vector3 Value; }
public struct Health : IComponentData { public int Value; public int Max; }
public struct DestroyTag : IComponentData { }
public struct DisabledTag : IComponentData, IEnableableComponent { }
public struct Parent : IComponentData { public Entity Value; }

WARNING

The spec text in docs/API.md §1 shows ToTransform3D() as new Transform3D(new Basis(Rot, Scale), Pos). The implementation takes a different path: new Transform3D(new Basis(Rot).Scaled(Scale * Vector3.One), Pos). Prefer uniform scale via Identity / explicit Scale; verify non-uniform needs against Basis semantics.

Type registry

csharp
public enum Allocator { TempJob, Persistent }

[System.AttributeUsage(System.AttributeTargets.Struct, Inherited = false)]
public sealed class ComponentVersionAttribute : System.Attribute
{
    public int Version { get; }
    public ComponentVersionAttribute(int version) { Version = version; }
}

public readonly struct TypeIndex : System.IEquatable<TypeIndex>
{
    public readonly int Value;
    public TypeIndex(int value) { Value = value; }
    public bool Equals(TypeIndex other) => Value == other.Value;
    public override bool Equals(object obj) => obj is TypeIndex t && Equals(t);
    public override int GetHashCode() => Value;
    public static bool operator ==(TypeIndex a, TypeIndex b) => a.Value == b.Value;
    public static bool operator !=(TypeIndex a, TypeIndex b) => a.Value != b.Value;
    public override string ToString() => $"TypeIndex({Value})";
    public static TypeIndex Of<T>() where T : struct => TypeRegistry<T>.Index;
}

public readonly struct ComponentTypeInfo
{
    public readonly TypeIndex Index;
    public readonly System.Type Type;
    public readonly int Size;
    public readonly int Align;
    public readonly int Version;
    public readonly bool IsShared;
    public readonly bool IsBuffer;
    public readonly bool IsEnableable;
    public ComponentTypeInfo(TypeIndex index, System.Type type, int size, int align, int version,
        bool isShared, bool isBuffer, bool isEnableable);
}

public static class TypeRegistry
{
    public static bool TryGetInfo(System.Type type, out ComponentTypeInfo info);
    public static ComponentTypeInfo GetInfo(TypeIndex index);
    public static int Count { get; }
}

public static class TypeRegistry<T> where T : struct
{
    public static readonly TypeIndex Index;
    public readonly static int Size;             // Unsafe.SizeOf<T>()
    public static readonly int Align;            // heuristic: min(8, nextPow2(Size))
    public static readonly int ComponentVersion; // from [ComponentVersion(n)], default 1
    public static readonly bool IsShared;
    public static readonly bool IsEnableable;
    public static readonly bool IsBuffer;
}

Each component struct gets one TypeIndex, assigned thread-safely at first touch. Marker detection runs once per type in the static constructor (never in hot paths). GetInfo with an unregistered index throws ArgumentOutOfRangeException. Align is currently a safe over-estimate heuristic, not the exact native alignment.

Archetype and Chunk

csharp
public sealed class Archetype
{
    public int Id { get; }
    public int Hash { get; }
    public TypeIndex[] Types { get; }      // sorted
    public int[] Sizes { get; }
    public int[] Offsets { get; }          // column byte offset inside chunk storage
    public int Stride { get; }
    public int Capacity { get; }           // rows per chunk
    public System.Collections.Generic.IReadOnlyList<Chunk> Chunks { get; }
    public bool IsEnableable(int column);
    public bool Has(TypeIndex t);
    public bool TryGetColumn(TypeIndex t, out int column);
    public int GetColumn(TypeIndex t);     // throws InvalidOperationException if absent
    public T GetSharedValue<T>(TypeIndex t) where T : struct;
}

public sealed class Chunk
{
    public const int SizeBytes = 16 * 1024;
    public Archetype Archetype { get; }
    public int Capacity { get; }
    public int Count { get; internal set; }
    public Godot.Vector3 AabbMin;
    public Godot.Vector3 AabbMax;
    public bool HasAabb;
    public System.Span<T> GetSpan<T>(int column) where T : struct;
    public ref T GetRef<T>(int column, int row) where T : struct;
    public Entity GetEntity(int row);
    public System.Span<Entity> EntitySpan { get; }
    public uint GetChangeVersion(int column);
    public void SetChangeVersion(int column, uint version);
    public bool DidChange(int column, uint lastSystemVersion);
    public bool IsEnabled(int column, int row);
    public void SetEnabled(int column, int row, bool enabled);
    public void SetAabb(in Godot.Vector3 min, in Godot.Vector3 max);
    public void InvalidateAabb();
    public void ExpandAabb(in Godot.Vector3 p);
}

public readonly struct ChunkRef
{
    public readonly Chunk Chunk;
    public ChunkRef(Chunk chunk) { Chunk = chunk; }
    public int Count { get; }
    public Archetype Archetype { get; }
    public System.Span<T> GetSpan<T>(int column) where T : struct;
    public Entity GetEntity(int row);
    public System.Span<Entity> Entities { get; }
}

Chunk.GetSpan<T>(column) returns the live rows [0, Count) as a span — zero allocations. SetEnabled on a non-enableable column is a no-op returning true on read. The chunk AABB (AabbMin/Max/HasAabb) is maintained by culling/physics systems, not by the chunk itself.

EntityManager

csharp
public sealed class EntityManager
{
    // Creation
    public Entity Create();
    public void CreateBatch(System.Span<Entity> outEntities);
    public Entity Instantiate(Entity prefab);   // deep-copies components + buffers + enable bits
    public void Destroy(Entity e);              // swap-remove; bumps version; throws if dead
    public void DestroyBatch(System.ReadOnlySpan<Entity> entities);
    public int CopyLiveEntities(System.Span<Entity> dst); // index order; returns count copied
    public int LiveCount { get; }
    public bool IsAlive(Entity e);

    // Structural (main thread or via ECB only; never inside a parallel job)
    public void AddComponent<T>(Entity e) where T : struct, IComponentData;       // no-op if present
    public void RemoveComponent<T>(Entity e) where T : struct, IComponentData;    // no-op if absent
    public void SetEnable<T>(Entity e, bool enable) where T : struct, IEnableableComponent;
    public bool IsEnabled<T>(Entity e) where T : struct, IEnableableComponent;    // true if column absent

    // Data
    public void SetComponentData<T>(Entity e, in T data) where T : struct, IComponentData;
    public T GetComponentData<T>(Entity e) where T : struct, IComponentData;
    public bool HasComponent<T>(Entity e) where T : struct, IComponentData;       // false if dead

    // Shared (change = archetype move; use sparingly)
    public void SetShared<T>(Entity e, in T data) where T : struct, ISharedComponent;
    public T GetShared<T>(Entity e) where T : struct, ISharedComponent;

    // Buffers (tracked per (type, entity) outside archetypes; queries ignore them)
    public DynamicBuffer<T> GetBuffer<T>(Entity e) where T : struct, IBufferElementData;

    // Queries
    public EntityQuery CreateQuery(in QueryDesc desc);
    public int Count(EntityQuery query);
    public uint GlobalSystemVersion { get; }
    public void BumpSystemVersion();
}

Behaviour notes: every structural change and every value write bumps GlobalSystemVersion (bump-then-stamp), so all writes are observable via DidChangeSince. AddComponentRaw-style internals power ECB playback and snapshot load. Instantiate copies every shared column value and deep-clones all dynamic buffers.

WARNING

EntityManager has no Instantiate(EntityScene) overload — that snippet in docs/API.md §2 does not exist in the implementation. Instantiate prefabs via EntityScene.Instantiate(world, pos) (see Serialization).

DynamicBuffer, EntityQuery, QueryDesc

csharp
public struct DynamicBuffer<T> where T : struct, IBufferElementData
{
    public int Length { get; }          // throws InvalidOperationException on default struct
    public int Capacity { get; }
    public ref T this[int i] { get; }   // throws ArgumentOutOfRangeException
    public void Add(in T item);         // doubles capacity when full
    public void Clear();
    public System.Span<T> AsSpan();
}

public struct QueryDesc
{
    public System.Type[] All;
    public System.Type[] Any;
    public System.Type[] None;
    public static QueryDesc Of<TAll>() where TAll : struct;
}

public sealed class EntityQuery
{
    public bool Matches(Entity e);
    public int Count();
}

WARNING

DynamicBuffer<T> exposes no raw Ptr — the unsafe pointer form in docs/API.md §2 is not implemented (the project does not enable AllowUnsafeBlocks). Use the indexer / AsSpan().

CreateQuery throws ArgumentException on a null type entry or a type never registered as a component. Query chunk caches refresh on structural change; the matching-chunk span is internal (the scheduler and EntityQueryExt consume it).

Query factory and extensions

csharp
public static class Query
{
    public static QueryDesc Of<T0>() where T0 : struct;
    public static QueryDesc Of<T0, T1>() where T0 : struct where T1 : struct;
    public static QueryDesc Of<T0, T1, T2>() where T0 : struct where T1 : struct where T2 : struct;
    public static QueryDesc Of<T0, T1, T2, T3>() where T0 : struct where T1 : struct where T2 : struct where T3 : struct;
    public static QueryDesc Of<T0, T1, T2, T3, T4>()
        where T0 : struct where T1 : struct where T2 : struct where T3 : struct where T4 : struct;
}

public static class EntityQueryExt
{
    // True if any All column of any matching non-empty chunk changed since lastSystemVersion.
    public static bool DidChangeSince(this EntityQuery q, uint lastSystemVersion);
    // Rows with the enableable bit set; archetypes lacking the column count all rows.
    public static int CountEnabled<TEnable>(this EntityQuery q)
        where TEnable : struct, IEnableableComponent;
}

Query.Of builds All-only descriptors without reflection in hot paths (touches TypeRegistry<T>.Index, a one-time setup cost). Both extensions allocate nothing and use no closures.

EntityCommandBuffer

csharp
public struct EntityCommandBuffer : System.IDisposable
{
    public EntityCommandBuffer(Allocator allocator);
    public bool IsCreated { get; }
    public int Count { get; }
    public Entity Create();                                                     // temp handle (Version = int.MinValue)
    public void Destroy(Entity e);
    public void Add<T>(Entity e) where T : struct, IComponentData;
    public void Remove<T>(Entity e) where T : struct, IComponentData;
    public void Set<T>(Entity e, in T data) where T : struct, IComponentData;
    public void SetEnable<T>(Entity e, bool enable) where T : struct, IEnableableComponent;
    public Entity Instantiate(Entity prefab);                                   // temp handle
    public void Playback(EntityManager dst);    // sorted by (sortKey, seq); does NOT clear
    public void Clear();                        // reuses capacity
    public void Dispose();                      // == Clear()
    public ParallelWriter AsParallelWriter();

    public struct ParallelWriter
    {
        public Entity Create(int sortKey);
        public void Destroy(int sortKey, Entity e);
        public void Add<T>(int sortKey, Entity e) where T : struct, IComponentData;
        public void Remove<T>(int sortKey, Entity e) where T : struct, IComponentData;
        public void Set<T>(int sortKey, Entity e, in T data) where T : struct, IComponentData;
        public void SetEnable<T>(int sortKey, Entity e, bool enable) where T : struct, IEnableableComponent;
        public Entity Instantiate(int sortKey, Entity prefab);
    }
}

Recording is thread-safe (fine-grained lock, setup-side cost only). Create / Instantiate return temporary entities usable only inside the same buffer and resolved at Playback. Playback sorts by (sortKey, seq): main-thread records use sortKey 0 in registration order; parallel writers pass the chunk index so results are worker-order independent. An op ordered after a Destroy on the same entity throws InvalidOperationException — record the destroy with a larger sortKey (e.g. int.MaxValue) when one pass destroys and touches the same entity. Using a default-constructed (uninitialized) buffer throws.

World and EcsRng

csharp
public struct EcsRng
{
    public ulong State;
    public EcsRng(ulong seed) { State = seed != 0 ? seed : 0x9E3779B97F4A7C15UL; }
    public uint NextUInt();
    public float NextFloat();                          // [0, 1)
    public float NextRange(float min, float max);
    public EcsRng Fork(uint streamId, uint tick);      // SplitMix over State + stream + tick
}

public sealed class World : System.IDisposable
{
    public string Name { get; }
    public uint Tick { get; internal set; }
    public EntityManager Entities { get; }
    public SystemGroup Initialization { get; }
    public SystemGroup FixedStep { get; }
    public SystemGroup Variable { get; }
    public SystemGroup Presentation { get; }
    public EcsRng Rng { get; private set; }
    public float LastFixedDelta { get; private set; }
    public double Alpha { get; set; }
    public ulong SpillCount { get; private set; }
    public World(string name, uint baseSeed);
    public static World Default { get; }    // lazy, thread-safe shared instance
    public static World Preview { get; }    // editor-only; use Default or dedicated worlds in builds
    public T GetSingleton<T>() where T : struct, ISingleton;      // throws if never set
    public void SetSingleton<T>(in T value) where T : struct, ISingleton;
    public void TickFixed(float frameDelta);  // accumulator: 0..MaxSubsteps steps, drops backlog
    public void Dispose();
}

new World seeds FixedConfig.Default and a zeroed EcsTime singletons. TickFixed sanitizes frameDelta (negative/NaN/Infinity → 0), derives the step from the world's FixedConfig (FixedDelta = 1/TargetHz), runs FixedStep.Update up to MaxSubsteps times, drops backlog beyond that (SpillCount++, no spiral), and refreshes Tick, Alpha, LastFixedDelta, and the EcsTime singleton. Each group's SystemState gets Rng.Fork(groupStreamId, Tick) — deterministic per group and tick. Never use System.Random in simulation logic.

Systems

csharp
public struct SystemState
{
    public World World { get; }
    public EntityManager Entities { get; }
    public EntityCommandBuffer ECB { get; }
    public float FixedDelta { get; }   // step in FixedStep, LastFixedDelta elsewhere
    public float Delta { get; }
    public double Alpha { get; }       // Prev -> Curr interpolation factor for Presentation
    public uint Tick { get; }
    public uint LastSystemVersion { get; }
    public EcsRng Rng { get; }
    public EntityQuery Query(in QueryDesc desc);   // setup-time; cache the result in OnCreate
    public Jobs.JobHandle ScheduleParallel<TJob, T0>(ref TJob job, EntityQuery query, int batchSize = 128)
        where TJob : struct, Jobs.IJobEntityChunk<T0> where T0 : struct;
    public Jobs.JobHandle ScheduleParallel<TJob, T0, T1>(ref TJob job, EntityQuery query, int batchSize = 128)
        where TJob : struct, Jobs.IJobEntityChunk<T0, T1> where T0 : struct where T1 : struct;
    public Jobs.JobHandle ScheduleParallel<TJob, T0, T1, T2>(ref TJob job, EntityQuery query, int batchSize = 128)
        where TJob : struct, Jobs.IJobEntityChunk<T0, T1, T2>
        where T0 : struct where T1 : struct where T2 : struct;
    public Jobs.JobHandle ScheduleParallel<TJob, T0, T1, T2, T3>(ref TJob job, EntityQuery query, int batchSize = 128)
        where TJob : struct, Jobs.IJobEntityChunk<T0, T1, T2, T3>
        where T0 : struct where T1 : struct where T2 : struct where T3 : struct;
    public Jobs.JobHandle ScheduleParallel<TJob, T0, T1, T2, T3, T4>(ref TJob job, EntityQuery query, int batchSize = 128)
        where TJob : struct, Jobs.IJobEntityChunk<T0, T1, T2, T3, T4>
        where T0 : struct where T1 : struct where T2 : struct where T3 : struct where T4 : struct;
}

public interface ISystem
{
    void OnCreate(ref SystemState state);
    void OnUpdate(ref SystemState state);
    void OnDestroy(ref SystemState state);
}

public abstract class SystemBase : ISystem
{
    public bool Enabled { get; set; } = true;
    public virtual void OnCreate(ref SystemState state) { }
    public abstract void OnUpdate(ref SystemState state);
    public virtual void OnDestroy(ref SystemState state) { }
}

public sealed class SystemGroup
{
    public string Name { get; }
    public void Add<T>() where T : struct, ISystem;
    public void Add(SystemBase system);
    public void SetEnabled<T>(bool enable);   // no-op if the system was never added
    public void Update(float delta);          // OnUpdate all enabled -> ECB playback -> BumpSystemVersion
}

[System.AttributeUsage(System.AttributeTargets.Struct | System.AttributeTargets.Class)]
public sealed class UpdateInGroupAttribute : System.Attribute
{
    public System.Type Group { get; }
    public UpdateInGroupAttribute(System.Type group) { Group = group; }
}
[System.AttributeUsage(System.AttributeTargets.Struct | System.AttributeTargets.Class)]
public sealed class UpdateBeforeAttribute : System.Attribute
{
    public System.Type Target { get; }
    public UpdateBeforeAttribute(System.Type system) { Target = system; }
}
[System.AttributeUsage(System.AttributeTargets.Struct | System.AttributeTargets.Class)]
public sealed class UpdateAfterAttribute : System.Attribute
{
    public System.Type Target { get; }
    public UpdateAfterAttribute(System.Type system) { Target = system; }
}

Notes: SystemState is passed by ref and never heap-allocated. All five ScheduleParallel overloads take components ref (read-write); the spec's in (read-only) variants are a Source Generator task, not yet implemented — jobs currently stamp change versions on every scheduled column. Add<T> boxes the struct system once at setup. Ordering inside a group is a stable topological sort over [UpdateBefore/After] computed at Add; a dependency cycle throws InvalidOperationException. Update on an empty group is a no-op. Systems added after the first Update get OnCreate immediately.

GodotECS docs — version 0.1.0. All rights reserved until a license is added.