Entity Component System
The hand-rolled ECS that runs the game loop — World, component stores, the parallel-wave scheduler, and how component changes reach clients.
zone-server’s game loop is a hand-rolled ECS (no external ECS library) living under
zone-server/src/main/kotlin/net/bestia/zone/ecs/. Everything gameplay-related — position, health,
inventory, AI state — is a component on an entity, and every rule that acts on that data is a
System.
World
Gameplay code sees World (ecs/core/World.kt), an interface with the entities, components and
queries: get, add, remove, query, createEntity, destroy, defer, post. Systems,
tick-lane handlers and the block of a WorldView scope all receive it. EcsWorld implements it and
adds the engine side that only ZoneEngine, boot runners and tests need: the tick, the tick thread,
the listeners and the dirty log. Its doc comment states the tick pipeline plainly:
tick(dt):
1. run posted work -> tick-lane messages, leases, skill resolutions
2. run due systems -> scheduler (parallel waves)
3. apply deferred structural changes emitted by systems
The tick thread owns the world and calls its accessors (get, add, query, …) directly. Other
threads (IO-lane handlers, schedulers, DB jobs) never call an accessor. They either post { ... } work
to the tick thread, or use a WorldView scope (read/modify/createEntity), which borrows the
world for a moment between two ticks — see Threads and the Tick. On the
tick, world.modify(id) { ... } reads the same as the scope but borrows nothing: it runs the block
only if the entity is alive. Structural changes requested while systems are iterating
(world.defer { ... }) are automatically pushed to the next safe sync point rather than corrupting an
in-progress iteration.
// The common "get or create, then mutate" pattern used across systems (an extension on World):
world.update(entityId, default = { Exp() }) { exp ->
exp.addExperience(150) // mutating through the component's own setter marks it dirty
}
Components, stores, and queries
A component is a plain data class implementing the marker Component interface. EcsWorld
lazily creates a ComponentStore<T> per component type. World.query(...) builds a Query that
joins several stores by entity id, iterating the smallest store and skipping entities missing any
of the rest — iteration cost is proportional to the rarest component in the join, not the whole
world:
world.query(Position::class, Speed::class).each { id ->
val pos = get<Position>()
val speed = get<Speed>()
// ...
}
Systems and the wave scheduler
A System declares how often it runs, where in the tick it runs, and which component types it
reads/writes:
interface System {
val schedule: Schedule get() = Schedule.EveryTick
val phase: Phase
val after: Set<KClass<out System>> get() = emptySet()
val reads: ComponentClassSet get() = emptySet()
val writes: ComponentClassSet get() = emptySet()
fun update(world: World, deltaTime: Float)
}
Schedule is EveryTick, EveryTicks(n), or EverySeconds(seconds) — expensive systems (e.g.
something that only matters every few minutes) don’t need to run every tick, and when they do run
less often, deltaTime is the real elapsed time since they last ran, not just one tick’s worth, so
time-integrating logic (countdowns, decay) stays correct regardless of cadence.
The tick order
A tick runs in phases, in this order: AI, MOVEMENT, ACTIONS, WORLD, STATUS, COMBAT,
RECOVERY, ITEMS, DEATH, SPAWN, UPKEEP, PERSIST. Inside a phase, after names the systems
that must run first:
@Component
class CarryCapacitySystem(...) : System {
override val phase = Phase.ITEMS
override val after = setOf(ObtainItemIntentSystem::class, GainExpSystem::class)
...
}
Two systems conflict if one writes a component type the other reads or writes. TickOrder
resolves the order at boot and refuses to start when two conflicting systems of one phase are not
ordered by after, or when a system names a system of a later phase. Systems the rules leave
unordered run by name. An after can also order systems that share no component but whose effects
a client sees in sequence, such as the ground overlay after the chunk stream.
SystemScheduler then groups the systems into waves. A system goes in a later wave than every
system it conflicts with or runs after, and phases never share a wave. Systems within a wave may run
concurrently on the scheduler’s own ForkJoinPool when world.parallel-systems: true
(application.yml, default false — the whole simulation runs single-threaded by default and this
is a genuinely optional feature, not the normal mode).
Declared access is checked
The declarations are the whole contract, so a system must declare every component it touches,
including what its helpers touch on other entities. A shared helper exposes its own set for that
(EntityWriteBehind.READS, AttackExecutionService.READS and WRITES). With
world.undeclared-access: fail — set in tests, and in every testWorld() — a system that touches an
undeclared component fails for that tick with the system and component named. Production runs with
off, which costs nothing.
Registration
EcsConfiguration builds the World empty. Every System bean is registered into it only once all
singletons exist:
@Bean
fun systemRegistration(world: World, systems: ObjectProvider<System>, worldConfig: WorldConfig) =
SmartInitializingSingleton {
world.registerSystems(systems.stream().toList())
}
The World therefore depends on no system, so any service may inject the World or WorldView, even
one that a system depends on.
To add game logic: implement System, register it as a Spring @Component/@Service bean, give it
a phase, and declare accurate reads/writes sets. Boot tells you which after it still needs.
When a system fails
One broken system must not stop the world, so failures are contained at three levels:
- Per system. The scheduler catches what a system throws, logs it, and goes on with the next
system. The deferred changes and the client sync still happen on that tick. A system that fails
5 ticks in a row (
SystemScheduler.MAX_CONSECUTIVE_FAILURES) is switched off with an ERROR log. - Per entity. Inside a tick,
query(...).eachskips an entity whose action throws. More than 8 failures in one pass is a bug in the system, not in one entity, so it counts as a system failure. - Per deferred change and posted task. One that throws is logged; the rest still apply.
Only errors after which the JVM cannot be trusted (out of memory, internal VM errors) end the tick
loop, and ZoneEngine logs that the loop died instead of losing the thread silently.
ZoneEngine: the tick loop
ecs/ZoneEngine.kt owns the actual running loop. start() runs a loop on a dedicated
single-thread executor (zone-tick) that ticks the World at world.tick-rate (20 Hz by default,
application.yml). After every world.tick(dt) call, ZoneEngine.syncDirtyComponents() flushes
whatever changed out to clients — see below.
The step is fixed: every tick hands the systems exactly 1 / tick-rate seconds (50 ms), measured
on System.nanoTime, so a wall-clock jump cannot produce a negative or huge delta. FixedStepClock
counts the steps that are due. A late loop runs at most 3 steps back to back to catch up and drops
the rest, so after a long pause the world runs slow for a moment instead of racing. Dropped steps
show up in the slow-tick warning.
Dirty components and sync
A component says whether it needs re-sending through the Dirtyable interface:
interface Dirtyable {
val dirtyFlag: DirtyFlag
fun isDirty(): Boolean // dirtyFlag.isSet
fun markDirty() // force a resync even though nothing changed (e.g. client re-requests state)
fun clearDirty()
fun toEntityMessage(entityId: Long, removed: Boolean = false): EntitySMSG
fun syncTargets(world: World, entityId: EntityId): SyncTargets
}
Mutating a Dirtyable component through its own setters marks it dirty; a freshly added component
starts dirty. When its DirtyFlag goes from clean to dirty, it enters the entity into the world’s
DirtyLog — the component store attached the flag when the component was added. Each tick,
ZoneEngine drains that log rather than scanning every store, so an idle world costs nothing to
sync. For each entry that is still dirty it builds toEntityMessage() and resolves who should
receive it:
sealed interface SyncTargets {
data object PublicInRange : SyncTargets // everyone who sees the entity
data object OwnerOnly : SyncTargets // only the owning account
data class Accounts(val accountIds: Set<Long>) : SyncTargets // an explicit set (e.g. + party)
}
Position has a second flag, movedFlag, with its own log: every step re-indexes the entity in the
area-of-interest services (below), even the steps that are not sent.
Everything the sync has for one account, changes, removals, vanishes and snapshots, goes out as one
StateBatchSMSG stamped with World.tickCount. The batches go into the tick’s TickOutbox and
leave as one write per account when the tick ends, so the tick thread never blocks on network
I/O — see Networking.
An account that starts to see an entity (it walked into view, or the account received the chunk the
entity stands in) gets a full snapshot instead (EntitySnapshotBuilder): every component it may
see, visual first, then position, speed and path. That account skips the entity’s changed
components in the same tick, so a spawning entity reaches each client once and in order.
A component explicitly removed from a still-alive entity (implementing Removable) gets one more
sync call with removed = true; a whole entity being destroyed instead gets a VanishEntitySMSG
in the next sync’s batches, addressed to the union of every synced component’s targets.
AsyncJobExecutor is for database and other blocking work only. Its four workers each queue at most
2048 jobs; a job that does not fit is dropped and counted (and logged) rather than run on the
caller, because the caller is usually the tick.
Area of interest
Two different questions have two different answers:
- Who sees this entity? The accounts holding the chunk it stands in
(
EntityVisibility.observersOf, backed by the chunk subscriptions of the terrain stream), plus the player’s own account.EntityAudienceholds this rule. Component state and one-off events (OutMessageProcessor.sendToObserversOf) both use it, so they always reach the same clients. - What is near this position?
EntityAOIService, anAreaOfInterestService, keyed by entity id. Game logic uses it for range queries, such as perception and the victims of an area effect.
AreaOfInterestService answers “what is inside this box” with a uniform grid of 32 × 32-column
cells, one cell per chunk column. A step inside a cell is three array writes; a step into the next
cell moves the entity between two cell lists; a query visits only the cells it overlaps and builds
no tree. Each service holds two grids, one per AoiLayer, so a question about moving things never
walks the tens of thousands of static ones (trees, rocks, buildings).
fun queryEntitiesInCube(center: Vec3L, size: Long, layers: Set<AoiLayer> = AoiLayer.ALL): Set<Long>
fun forEachInCube(center: Vec3L, size: Long, layers: Set<AoiLayer>, action: (Long, Long, Long, Long) -> Unit)
fun anyWithinHorizontal(x: Long, y: Long, radius: Long): Boolean
The grid takes no lock: it is only touched with the world to itself (on the tick thread, or inside a
world scope). The index follows Position.movedFlag in ZoneEngine.syncDirtyComponents(), so
every step is indexed, including the steps that are not sent to clients.
Last updated 05 Oct 2026, 23:42 +0200 .