Skip to main content

Arena: an ECS-style game loop (100 k units)

The sample everybody writes to evaluate an ECS: units move, fire projectiles at each other, projectiles hit or expire, units die and respawn — every tick, for a hundred thousand entities. Here it is on ephemeral tables, next to the same simulation hand-written as a struct-of-arrays ECS, driven by one RNG stream and cross-checked for identical results.

Source: ConjureDB.Samples.EcsDemo in the repository (domain, headless runner, Unity host).

Schema (excerpt)​

entity domain Actors { Unit, Body }
entity domain Shots { Projectile }

ephemeral table Projectile(plural: Projectiles, capacity: 16384) {
Id: int @id
Target: ref Body // generational handle — never hits a recycled id
X: float
Y: float
Vx: float
Vy: float
Damage: int
TtlTicks: int
}

event table HitEvent(capacity: 4096, lifetime: next_frame) {
Target: ref Unit
Damage: int
}

system IntegrateProjectiles(phase: fixed_update) {
from Projectile p
| set { p.X += p.Vx * @dt, p.Y += p.Vy * @dt, p.TtlTicks -= 1 }
}

The frame​

PhaseSystemWhat it does
FrameStartResolveRespawnsturns last frame's provisional spawn handles into real ones
FrameStartApplyHits (schema-declared)drives the HitEvent front buffer, join Unit u on ref (h.Target), set { u.Health -= h.Damage }, match { u.Health <= 0 => { event DeadSlot … delete entity u } }
FixedUpdateFire256 random shots per tick: a counted from generate(…) driver, two lookup … on id probes, one insert Projectile
FixedUpdateCollision (schema-declared)join Body b on ref (p.Target) then one match arm per projectile: miss → lost target, in radius → event HitEvent + delete p, ttl → expired
FixedUpdateIntegrateBodies, IntegrateProjectilesschema-declared kernels
UpdateClock (schema-declared)from ArenaClock c | set { c.Tick += 1, c.FixedDt = @fixed_dt }
LateUpdatePresentationSyncstreams positions into the slot-indexed presentation buffer (a TransformAccessArray in Unity)
FrameEndRespawnrefills dead slots through the command buffers; resolves next frame

The scheduler refuses to seal if two unordered systems overlap. Collision orders itself in the schema (before: [IntegrateBodies, IntegrateProjectiles]); the hand-written Fire slots in front of it from the OnFrameRuntimeRegistered hook. Five of the nine systems are now schema declarations; the four that stay hand-written are the random-number consumer (Fire), the roster bookkeeping (ResolveRespawns, Respawn), and the presentation sink.

Numbers​

Apple M4 Max, .NET 8.0.11, single thread, 120 warm-up + 600 measured frames, 100 000 units, 256 shots per tick, ~23 000 live projectiles.

enginep50p99meanallocations
ConjureDB ephemeral tables452 µs694 µs459 µs0 B in every system; ~6 KB/frame of scheduler growth spikes
hand-written SoA ECS336 µs1 910 µs424 µs0 B

Both engines report the same presentation digest, the same 2 118 hits and the same deaths.

Per system, ConjureDB:

systemmean µs
Collision (23 k target probes)151
IntegrateBodies (100 k rows)97
PresentationSync (100 k rows)63
Fire (256 shots)45
IntegrateProjectiles (23 k rows)35
ApplyHits1.4

Frame-rate queries a UI would run between two pumps, at 100 k units:

queryµsbytes
CountUnits() (cardinality only)50
GetWeakestUnits(10) ForEach (bounded Top-N)1650
GetBodiesInBox(±50) ForEach (filtered scan)3770
GetHitTotalsByTarget() (grouped aggregate over the event stream)56.7 K

Unity: Mono, IL2CPP and Burst​

The same domain assembly (its netstandard2.1 build) runs in a Unity 6000.7.0a2 player with the scheduler injected into the PlayerLoop. The bootstrap paces the player with Time.captureDeltaTime so every frame carries exactly one fixed tick, like the headless runner, and reads the completed frame's report from LateUpdate. Same machine, 120 warm-up + 600 measured frames:

hostunitskernelsframe p50IntegrateBodiesPresentationSync
.NET 8 headless100 kgenerated scalar497 µs98 µs63 µs
Unity IL2CPP100 kgenerated scalar484 µs61 µs81 µs
Unity IL2CPP100 kBurst job graphs for both streaming systems436 µs35 µs34 µs
Unity IL2CPP1 Mgenerated scalar2 088 µs618 µs822 µs
Unity IL2CPP1 MBurst job graphs for both streaming systems1 003 µs163 µs155 µs
Unity Mono100 kgenerated scalar2 061 µs259 µs260 µs
Unity Mono100 kBurst, inline1 856 µs70 µs261 µs

Every mode reproduces the managed run's presentation digest and simulation counters; the Burst kernels are bit-identical to the generated scalar oracle.

The Burst kernels are host-side IFrameSystems that declare the same manifest the compiler derived for the declared systems and are installed before sealing:

var context = DbContextBuilder<ArenaDbContext>.Create()
.WithNoPersistence()
.ConfigureFrameRuntime(ctx =>
{
ctx.Frame.TryGetSystemId(new FrameSystemName("IntegrateBodies"), out var integrateBodies);
ctx.Frame.SubstituteImplementation(integrateBodies, new BurstIntegrateBodiesSystem(ctx));
})
.Build();

Inside Run, a system takes each chunk's column spans through the frame-scoped accessors, turns them into pointers with EphemeralChunkMemory.PointerOf (chunk memory is pin-stable for the table's lifetime), schedules one [BurstCompile] job per chunk over the table's own memory with the storage's enabled bitmap as the mask, and completes the combined handle once before returning.

What it taught us:

  • IL2CPP alone closes the gap. The IL2CPP player runs the frame at .NET 8 speed, and its C++-compiled generated kernel is as fast as a Burst kernel run inline.
  • One completion per system is what makes jobs pay. The first attempt pinned each chunk with fixed and completed each chunk's job in its own scope: 179 µs for a 61 µs kernel. The same job as one graph: 35 µs.
  • Streaming kernels are bandwidth-bound. At 1 M rows the graphs deliver 3.8× and 5.1× on 15 workers because the loops move ~200 GB/s; the frame halves because those two systems are two thirds of it. At 100 k the frame gains 10–12 %: collision, half the frame, is serial by design (generation-checked probes into another table, event appends).
  • Burst codegen is the Mono escape hatch (3.7× on the kernel there), not an IL2CPP one.

Full steps, logs and the research notes: the ConjureDB.Samples.EcsDemo.Unity sample in the repository.

DOTS baseline​

The same simulation ported to Unity Entities 1.4.8 (ISystems, Burst IJobEntity jobs and a Burst-compiled fire system, an EntityCommandBuffer per tick, NativeQueue hit events, Entity references as generation-checked targets), driven in the same phase order on the same IL2CPP host, Burst execution verified by a probe. Same RNG draw order, same presentation digest.

unitsengineframe p50FireCollisionIntegrateBodiesPresentationSync
100 kDOTS, parallel collision job190 µs16 µs53 µs31 µs38 µs
100 kDOTS, serial collision job430 µs21 µs280 µs—42 µs
100 kConjureDB, generated kernels484–506 µs37–41 µs229–245 µs61 µs81 µs
100 kConjureDB, Burst job graphs423–436 µs37 µs244 µs35 µs33–34 µs
1 MDOTS, parallel collision job491–513 µs40–43 µs73–78 µs—180–187 µs
1 MConjureDB, Burst job graphs985–1 031 µs79 µs494–503 µs158–177 µs153–169 µs

Two facts. The storage kernels are at parity: integration, projectile integration and presentation cost the same in both designs, and ConjureDB's serial collision pass is faster than the equivalent serial Burst job. The 2× that Entities kept on the frame was three measurable items on the ConjureDB side — the collision probe as a parallel job, per-record command authorization, and boundary playback — and they were closed the same day:

unitsengineframe p50FireCollisionboundary
100 kConjureDB, generated kernels, before484–506 µs37–41 µs229–245 µs≈60 µs
100 kConjureDB, generated kernels, after394 µs12 µs204 µs≈20 µs
100 kConjureDB, job graphs + Burst collision171 µs15 µs43 µs≈20 µs
100 kDOTS baseline190 µs16 µs53 µs36 µs
1 MConjureDB, generated kernels, after1 804 µs37 µs322 µs≈20 µs
1 MConjureDB, job graphs + Burst collision453 µs42 µs62 µs≈20 µs
1 MDOTS baseline491–513 µs40–43 µs73–78 µs195–204 µs

What changed: Commands.AcquireRecorder authorizes a system's structural records once (the recording counterpart of the row leases), boundary playback guards each table once and replays runs of creates as one bulk append, and AcquireEntityLookup hands a host job the table's pin-stable id→row pages so the collision probe runs on worker threads while the frame thread drains its outcomes in serial order. Same digests, 0 B/frame.

What the sample taught us​

Building it surfaced the sharp edges you would hit too, and they are fixed in the runtime rather than worked around in the sample: per-call manifest checks on TryGetRow were 17× the storage cost of the probe (hence the row leases), a spawn recorded at FrameEnd could not be resolved at FrameStart (resolutions now survive empty boundaries), and hand-written systems had no way to order themselves against generated kernels (hence the OnFrameRuntimeRegistered hook). The remaining accepted losses are the ones every schema-first engine has: a hand-vectorized ECS iterates faster, and spawn storms pay for command-buffer playback.