Core API
GodotECS.Core — entities, components, type registry, chunks, entity manager, queries, command buffers, worlds, systems. Source: addons/godotecs/src/core/.
Component markers
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
[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
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
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
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
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
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
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
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
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
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.