Core concepts
How GodotECS stores your data, finds it, mutates it safely, and runs it deterministically. This page explains the model; exact signatures live under API reference.
Archetypes and chunks
An archetype is the identity of a set of entities: the sorted list of component type indices plus shared-component values. LocalTransform + Velocity is one archetype; LocalTransform + Velocity + Health is another. Adding or removing a component moves the entity to a different archetype — the move is cached in an edge graph, so repeated transitions are cheap, but structural changes are never free. Design components so their set rarely changes at runtime.
Entities in one archetype live in chunks: fixed 16 KB blocks (Chunk.SizeBytes), each storing one archetype's components as struct-of-arrays columns. Iterating a chunk is a linear scan over dense arrays — cache-friendly by construction. Chunk capacity follows from archetype stride: small components pack hundreds of rows per chunk; huge components pack few.
// Archetype identity is derived automatically from the components you add.
Entity e = em.Create();
em.AddComponent<LocalTransform>(e); // move: empty archetype -> {LocalTransform}
em.AddComponent<Velocity>(e); // move: {LocalTransform} -> {LocalTransform, Velocity}TIP
Never call AddComponent / RemoveComponent inside a parallel job. Record them in an EntityCommandBuffer and let the system group play them back after OnUpdate. See ECB.
Implementation notes worth knowing:
- A brand-new component value on a moved entity is zero-initialized; values for components present in both archetypes are preserved.
Destroyuses swap-remove inside the chunk (the last row fills the gap), so row indices are unstable — never cache them.- Shared components (
ISharedComponent) are part of archetype identity: same type set with different shared values means different archetypes. Change them sparingly.
Entities
Entity is a small unmanaged struct — an index plus a version:
public readonly struct Entity : System.IEquatable<Entity>
{
public readonly int Index;
public readonly int Version;
}The version distinguishes generations: destroying entity (3:1) and recycling index 3 yields (3:2), so stale handles fail IsAlive instead of silently aliasing a new entity. Entities are cheap to copy and can be stored inside components (e.g. Parent.Value) — snapshot save/load remaps these internal references automatically.
Entity e = em.Create();
em.AddComponent<Health>(e); // Set needs the column to exist — Add first
em.SetComponentData(e, new Health { Value = 100, Max = 100 });
if (em.IsAlive(e) && em.HasComponent<Health>(e))
{
Health h = em.GetComponentData<Health>(e);
h.Value -= 10;
em.SetComponentData(e, in h);
}Entity.Null (Index = -1) is the sentinel for "no entity". Methods that validate liveness (GetComponentData, Destroy, …) throw InvalidOperationException on dead or stale entities; HasComponent and IsEnabled<T> return false / true respectively instead of throwing.
Components
Components are always unmanaged structs tagged with a marker interface:
| Marker | Meaning |
|---|---|
IComponentData | Plain per-entity data. Participates in archetypes and queries. |
IEnableableComponent | Toggable without a structural move (SetEnable). Needs IComponentData too when added via AddComponent. |
IBufferElementData | Element of a DynamicBuffer<T> (stored outside archetypes). |
ISharedComponent | Grouping value, part of archetype identity. Changing it moves the entity. |
ISingleton | One value per World, accessed via GetSingleton / SetSingleton. |
IBlobAsset | Reserved marker for future blob assets. |
Standard components always available:
em.SetComponentData(e, LocalTransform.Identity); // Pos=0, Rot=identity, Scale=1
em.AddComponent<DisabledTag>(e); // enableable: switch off without a move
em.SetEnable<DisabledTag>(e, false);
em.SetComponentData(e, new Parent { Value = mother });
em.AddComponent<DestroyTag>(e); // your cleanup system destroys theseDynamicBuffer<T> (inline capacity 4, doubling growth) is fetched per entity and mutated in place — no AddComponent needed, and queries cannot filter on buffer contents:
DynamicBuffer<CollisionEvent> hits = em.GetBuffer<CollisionEvent>(e);
hits.Add(new CollisionEvent { Other = other, Normal = n, Penetration = d });
foreach (CollisionEvent hit in hits.AsSpan()) { /* ... */ }Queries
A QueryDesc selects archetypes with All / Any / None type sets. Query.Of<…>() builds the common case (up to 5 All types); for Any / None construct the descriptor directly. Create the query once in OnCreate and cache it — building a query resolves types, iterating a cached one does not allocate.
public struct MoveSystem : ISystem
{
EntityQuery _q;
public void OnCreate(ref SystemState state)
{
_q = state.Query(Query.Of<LocalTransform, Velocity>());
}
public void OnUpdate(ref SystemState state)
{
// Skip the whole iteration when nothing relevant changed.
if (!_q.DidChangeSince(state.LastSystemVersion))
return;
var job = new MoveJob { Dt = state.FixedDelta };
state.ScheduleParallel<MoveJob, LocalTransform, Velocity>(ref job, _q).Complete();
}
public void OnDestroy(ref SystemState state) { }
}Query helpers:
EntityQuery.Matches(entity)— does one entity match right now.EntityQuery.Count()/EntityManager.Count(query)— live row count across matching chunks.EntityQueryExt.DidChangeSince(lastSystemVersion)—trueif anyAllcolumn in any matching chunk changed since that version. The standard early-out.EntityQueryExt.CountEnabled<TEnable>()— rows with the enableable bit set (archetypes without that column count all rows).
Change versions advance on every value write and every schedule, and once per system run — so "changed" means "touched since your system last ran", conservatively.
Entity command buffers
Structural changes (create, destroy, add, remove, set-from-a-job, instantiate) inside an iteration must be deferred, because moving entities would invalidate the chunks being iterated. Each SystemState carries an EntityCommandBuffer; the group plays it back after all systems in the group ran, in deterministic (sortKey, sequence) order.
public void OnUpdate(ref SystemState state)
{
// Main-thread recording, sortKey 0, immediate entity handles are temp:
// usable only inside this same buffer, resolved to real entities at playback.
Entity e = state.ECB.Create();
state.ECB.Set(e, new LocalTransform { Pos = spawn, Rot = Quaternion.Identity, Scale = 1f });
foreach (Entity dead in deadList)
state.ECB.Destroy(dead);
}Inside parallel jobs, use AsParallelWriter() and pass the chunk sortKey so playback order is independent of worker scheduling:
// Real pattern (as in tests/unit/JobsTest.cs): the job records temp entities,
// playback resolves them to real ones in deterministic (sortKey, seq) order.
public struct SpawnJob : IJobEntityChunk<LocalTransform>
{
public EntityCommandBuffer.ParallelWriter PW;
public void Execute(ref LocalTransform t, int entityIndex, int sortKey)
{
Entity tmp = PW.Create(sortKey); // temp handle, valid only in this buffer
PW.Set(sortKey, tmp, new LocalTransform
{
Pos = new Vector3(sortKey, 0f, 0f),
Rot = Quaternion.Identity,
Scale = 1f
});
}
}
// Wiring: hand the writer to the job, schedule, complete before playback.
EntityCommandBuffer ecb = state.ECB;
var job = new SpawnJob { PW = ecb.AsParallelWriter() };
state.ScheduleParallel<SpawnJob, LocalTransform>(ref job, query).Complete();WARNING
A job's Execute receives component refs plus entityIndex (row) and sortKey (chunk ordinal) — not the Entity itself. Record temp entities via the writer (PW.Create/PW.Set, resolved at playback) as above, or restructure so a main-thread pass applies tags after the job completes. Never call EntityManager structural methods from inside a job.
Rules that bite:
Playbackdoes not clear the buffer — the group-owned buffer is fresh per update, but a buffer you own must beClear()ed for reuse.- An op ordered after a
Destroyon the same entity throws. When one pass destroys and touches the same entity, record the destroy with a largersortKey(e.g.int.MaxValue).
Systems and groups
A system is a struct implementing ISystem (OnCreate / OnUpdate / OnDestroy receiving SystemState by ref) or a SystemBase class when you need reference semantics. Groups own the schedule:
World world = new World("Game", baseSeed: 1234);
world.FixedStep.Add<CopyPrevSystem>(); // first: snapshot Prev = Curr
world.FixedStep.Add<MoveSystem>();
world.FixedStep.Add<BroadphaseSystem>();
world.Variable.Add<MyUiPollSystem>(); // Godot-frame-rate logic
world.Presentation.Add<InterpolateSystem>();
// Per frame (bridge code):
world.TickFixed(frameDelta); // runs FixedStep 0..N times
world.Variable.Update(frameDelta);
world.Presentation.Update(frameDelta);SystemGroup.Update(delta) runs OnUpdate on every enabled system in order, then plays back the group's ECB, then bumps the global version. Ordering:
- Insertion order is stable by default — add
CopyPrevSystem/InterpolateSystemfirst by convention. [UpdateBefore(typeof(X))]/[UpdateAfter(typeof(X))]declare hard constraints; they are resolved topologically atAddtime and a cycle throws. References to absent types are ignored.SetEnabled<T>(false)skips a system without removing it.
SystemState gives each system everything it needs: World, Entities, ECB, FixedDelta (the step in FixedStep, last fixed delta elsewhere), Delta, Alpha (interpolation factor for Presentation), Tick, LastSystemVersion (for DidChangeSince), and a forked Rng — see determinism below.
Determinism
Same seed + same input sequence ⇒ same simulation. The pieces:
- Fixed timestep.
World.TickFixed(frameDelta)accumulates real time and runs theFixedStepgroup in fixed-size steps (FixedConfig.TargetHz, default 60). Logic must usestate.FixedDelta, neverstate.Delta, so a 30 Hz and a 144 Hz display simulate identical ticks. Backlog beyondMaxSubstepsis dropped (SpillCount++), never spiraled. - Seeded RNG, never
System.Random.EcsRng(XorShift64*) lives on the world; each system getsstate.Rng, forked per group and tick. Fork further per entity/chunk for order-independent randomness:csharpEcsRng rng = state.Rng.Fork((uint)sortKey, state.Tick); float crit = rng.NextRange(0f, 1f); - Deterministic job order. The scheduler snapshots matching chunks in creation order; the chunk index doubles as
sortKeyfor jobs and ECB playback, so parallel and sequential runs produce bit-identical results. Treat job struct fields as read-only insideExecute— accumulations there race; write results to components or the ECB. - Interpolation without touching logic.
CopyPrevSystemsnapshotsLocalTransform→PrevTransformat the start of each fixed step;InterpolateSystemwrites the display-onlyRenderTransformfromPrev/Curr+AlphainPresentation. Render/sync code readsRenderTransform; simulation readsLocalTransform. - Snapshots for verification.
Snapshot.Save/Snapshot.Hashproduce canonical bytes / FNV-1a hash of the world (tick included). Save at tick N in two runs and compare hashes to prove determinism.
// Minimal deterministic tick wiring (bridge / test):
var world = new World("Sim", baseSeed: 42);
world.FixedStep.Add<MovementSystem>();
world.FixedStep.Add<DamageSystem>();
world.FixedStep.Add<DestroySystem>();
world.TickFixed(1f / 60f); // one fixed step, Tick == 1
ulong hash = Snapshot.Hash(world); // compare across runs