Moving 100,000 positions is an easy way to benchmark an ECS. A real server tick also applies status effects, fires projectiles, handles deaths and respawns, and updates each player's area of interest. I built EasyEcs for that whole workload in C# and .NET 10.
On the project's 4-core 2.1 GHz Xeon VM, a warmed-up world of 100,000 entities runs nine simulation systems in 1.72 ms and allocates 0 B per tick. The systems do real work: setup checks that hits, deaths, spawns and grid changes happen. The benchmark source times world.Update() after warm-up.
| Native .NET workload | Measured time | Allocation after warm-up |
|---|---|---|
| 100k entities in one world, nine-system simulation tick | 1.72 ms | 0 B |
| Four independent worlds × 25k, one long-lived thread each | 0.92 ms total wall time | 0 B |
| 100k-entity battle-server example, with replication, indexes, relations and saves | 3.17 ms | 0 B |
These runs do different amounts of work. Four worlds can represent separate matches; 0.92 ms is the wall time until all four finish, including the benchmark's thread barriers. It does not describe one 100k-entity world. The results use BenchmarkDotNet 0.15.2 on Ubuntu 24.04 and .NET 10.0.13; they do not include network transport. The 1.68 ms cross-library tick mentioned below comes from a separate suite, not this 1.72 ms run. The performance guide gives the full setup.
Operations beyond the main loop
A tick is only part of a server's cost. These are head-to-head results for common operations on the same VM. “Next fastest” is whichever of Friflo.Engine.ECS 3.6, Arch 2.1 and DefaultEcs 0.17 was fastest in that case. Each library uses its idiomatic API; setup checks that the resulting state is equivalent.
| What the application does | EasyEcs | Next fastest | Gap |
|---|---|---|---|
| Save and load 10k entities | 146 μs | DefaultEcs 4.33 ms | 30× |
| Add then remove a status across 100k entities by query | 78.7 μs | Arch 1.21 ms | 15× |
| Spawn then destroy 10k entities beside a 100k world | 138 μs | Friflo 380 μs | 2.75× |
| Query 100k players by rating range and zone sums | 127 μs | Friflo 317 μs | 2.49× |
| Add then remove a status on 5% of 100k entities | 517 μs | Friflo 1.00 ms | 1.94× |
| Find the 10 of 100k entities changed this tick | 507 ns | Arch 966 ns | 1.91× |
| Iterate one component over 100k entities | 37.7 μs | Friflo 59.9 μs | 1.59× |
| Sleep and wake 10k of 100k entities | 478 μs | DefaultEcs 745 μs | 1.56× |
The save row writes and reloads 10,000 units with position and stock; the libraries use different serialization formats. The 30× gap measures that complete operation through each library's normal path, not identical encoders. More broadly, these ratios describe individual operations, not a 30× faster game.
On the full cross-library 100k tick, EasyEcs is 1.68 ms and Friflo 1.83 ms, both 0 B. Across all 37 tests EasyEcs leads in 24, is within measurement error in 11, and trails in 2. The clear losses are a scan that ignores EasyEcs's change filter and cold per-entity creation without reserved capacity. The comparison page shows those cases and the rest.
What the code looks like
An EasyEcs component is a struct. A system declares the data it reads (in) and writes (ref); the source generator builds the iteration job:
[ForEach]
static void Move(ref Position p, in Velocity v, in Clock clock)
{
p.X += v.X * clock.Dt;
p.Y += v.Y * clock.Dt;
}
The live browser demo takes contiguous spans from the same API and processes two particles per Vector128<float>:
[ForEach]
static void Move(
Span<Position> positions,
ReadOnlySpan<Velocity> velocities,
in Clock clock)
{
var p = MemoryMarshal.Cast<Position, Vector128<float>>(positions);
var v = MemoryMarshal.Cast<Velocity, Vector128<float>>(velocities);
var dt = Vector128.Create(clock.Dt);
for (int i = 0; i < p.Length; i++)
p[i] += v[i] * dt;
// The complete demo handles an odd last particle too.
}
The two demo jobs are real source files, not the nine-system server benchmark. The browser's roughly 0.1 ms motion reading is for drag plus movement on an M4 through Mono AOT → WebAssembly; it must not be compared directly with the Xeon/CoreCLR server tick.
In a separate one-archetype microbenchmark on the Xeon VM, the generated [ForEach] job updates 10,000 positions in 9.1 μs, or 0.91 ns per entity. A loop over separate Position[] and Velocity[] arrays takes 12.8 μs in that particular test. The microbenchmark source shows both loops. This is a narrow measurement, not a substitute for the full tick.
A round-wide stun and its removal take two calls:
static readonly QueryFilter Fighters = QueryFilter.Create().With<Vitals>();
world.SetAll(new Stunned(3), in Fighters);
world.RemoveAll<Stunned>();
After the first round, EasyEcs can keep the status column and flip presence bits instead of moving 100,000 rows again. That is why the 78.7 μs result is a warmed-up repeated change, not the cost of creating a new archetype from scratch. The bulk guide covers the semantics and the cases where per-entity updates are the right call.
What else is in the library?
The rest of a server benefits from the same data model:
-
[Changed<T>]and[Removed<T>]identify what to replicate. - Value indexes and relations answer lookups without scanning every entity.
- Prefabs and bulk spawning create waves; snapshots and saves support rollback and recovery.
- Parallel systems run together only when their generated read/write sets do not conflict.
The package also includes a Roslyn analyzer with 23 core diagnostics (EECS002–024) and 10 performance diagnostics (EECS025–034); some have code fixes. Native AOT is supported. The feature guide and analyzer guide go into the APIs and their costs.
Zero allocation has a boundary: a new world, first query shape, first archetype or growth past reserved capacity can allocate. The FAQ lists those cases. EasyEcs is an ECS library for .NET 10 game servers, simulations and other long-running real-time backends; it does not provide network transport and does not support Unity's current runtimes.
If this matches your workload, install it with dotnet add package EasyEcs, follow the getting-started guide, or run the 30 Hz GameLoop sample. The comparison and benchmark projects are public. Run a case from your own application, and open an issue if EasyEcs performs badly on it.












