Zero-Tick Engine
A world that doesn’t tick. It reacts.
Section titled “A world that doesn’t tick. It reacts.”Every player action, every hit and every buff is handled the moment it happens: no ticks, no races, no idle work.
Status: the engine core is in nx-l2 today, and the world moves onto it step by step. See the migration path.
Why Zero-Tick
Section titled “Why Zero-Tick”Faster
Section titled “Faster”Timers, buffs and cooldowns cost several times less than in classic builds.
No dupes
Section titled “No dupes”An item can’t end up in two places: inventory changes happen one at a time, on the space’s own thread. And the server checks it: every type that holds world state refuses a call from any other thread, so a stray thread fails loudly instead of quietly duplicating an item.
No races
Section titled “No races”A hit and a Sleep that land at the same moment always resolve the same way, in arrival order. An attack wakes a
sleeping target, so the order decides the fight. There are no locks left to forget: the world entities shed 119
synchronized blocks, 155 concurrent collections and 43 atomics for one owner thread and a check.
Instant reaction
Section titled “Instant reaction”Nothing waits for “the next tick”: an action is handled right away.
No idle work
Section titled “No idle work”Every wake-up is a real event: no empty beats like tick builds, no tasks spinning on empty pools like pool builds.
See everything
Section titled “See everything”Where it slows down, in which phase, in which pass: Prometheus metrics out of the box, ready for your Grafana.
Deterministic by design
Section titled “Deterministic by design”A pass is deterministic: time and randomness come only from the pass, and the seed is logged, so the same inputs give the same fight. Recording live inputs for replay is on the roadmap.
Zero-Tick vs classic builds
Section titled “Zero-Tick vs classic builds”| Feature | Zero-Tick | L2J Mobius | aCis | L2J Server | L2-Scripts line | L2E (Elixir) |
|---|---|---|---|---|---|---|
| Wakes only on events, no polling | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| No locks on game state, one writer | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ |
| Dupe-proof by design | ✓ | ✗ | ✗ | ✗ | ✗ | — |
| Thread ownership checked at runtime | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ |
| Deterministic passes (seeded RNG) | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Built-in Prometheus metrics | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Pass budget with per-phase breakdown | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
By the public source code of each family; ✓ yes, ✗ no, — not established. The facts behind every cell are in the detailed comparison under the hood.
Tick vs Zero-Tick
Section titled “Tick vs Zero-Tick”A classic server wakes on a fixed tick, for example every 100 ms, and handles whatever piled up since the last one. It looks like “everything at once”, but it is not. An action that arrives just after a tick waits up to 100 ms for the next one. Inside the tick the events still run one after another, in whatever order they sit in the list. A faster tick, even 100 times a second, only shortens the wait; it never makes two events simultaneous. Zero-Tick drops the wait and keeps the order fixed: each event is handled when it arrives, in an order that is the same on every run.
A tick makes the world wait. Zero-Tick doesn’t.
Under the hood
Section titled “Under the hood”How a pass works
Section titled “How a pass works”A space of the world (a partition) is an event loop on its own thread. Nothing polls it:
- A Netty IO thread (epoll on Linux) decodes a client packet and puts it into the session’s queue.
- The session’s ready flag goes up and the partition inbox receives “this session has input”; if the loop thread is
parked,
LockSupport.unparkwakes it. - With no input pending, the loop parks in
LockSupport.parkNanosuntil the nearest deadline: the next timer or the next due periodic system. With nothing due at all, it stays parked.
When it wakes, the loop reads the clock once and runs one pass in a fixed order of six phases:
flowchart TB I[Inbox<br/>input and messages] --> D[Deadlines<br/>due timers] D --> S[Systems<br/>due periodic systems] S --> E[Side effects<br/>deaths, drops, stats] E --> R[Replication<br/>what each client sees] R --> F[Flush<br/>one write per connection]
- One clock reading per pass. Every mechanic in the pass sees the same “now”, which is what makes a pass reproducible.
- Deterministic order. Due timers run in
(deadline, sequence)order; the sequence keeps registration order for equal deadlines. - Bursts share a pass. A pass has a minimum distance to the next one (
pass-min, see operations), so a burst of input arriving within it is handled in one pass instead of many. - Same-pass follow-ups. Work triggered inside a pass (a triggered skill, a death caused by a damage tick) runs in the same pass, up to a depth limit, so a trigger chain cannot loop forever.
- Side effects in a fixed order. Deaths, drops and rewards are deferred to the side-effect phase and run there first in, first out; work they defer runs after them in the same phase. A monster’s death takes a snapshot of who hit it and for how much, so the drop, the event drop and the experience that follow are decided by that fight, not by whatever the aftermath of the death has changed.
Zero-Tick, ticks and ECS
Section titled “Zero-Tick, ticks and ECS”Tick versus Zero-Tick is a scheduling model: it decides when work runs. ECS (entity-component-system) is a data and code model: it decides how state and logic are laid out. The two are orthogonal. In practice ECS engines such as Bevy, Unity DOTS and Flecs almost always run on a tick: every frame the schedule iterates all entities that carry the components a system needs.
| Classic L2J (ticks + pools) | ECS (Bevy-like) | Zero-Tick | |
|---|---|---|---|
| When work runs | fixed-rate tasks and ticks | every frame | on input, a due deadline or a due periodic system |
| Work per wake-up | all due tasks, even empty ones | all entities with the component, O(entities) per tick | only what is affected, O(events) |
| Threading | shared pool plus synchronized: races and dupes |
the scheduler runs systems in parallel by component access, no locks | one writer thread per partition, partitions in parallel |
| Order and determinism | up to the OS scheduler | deterministic with a fixed step | one “now” per pass, (deadline, sequence) order |
| Strong side | simple and familiar | dense work every frame, cache-friendly iteration | sparse work: most objects idle, rare precise events such as buffs, cooldowns, respawns and hits |
| Weak side | races, idle tasks, no visibility | empty ticks, latency up to one step, every script rewritten | dense per-frame work has to go to periodic systems |
Why Lineage 2 fits Zero-Tick. An MMO world is sparse: thousands of NPCs stand idle, and every player carries dozens of buffs and cooldowns, each ending at its own millisecond. An ECS tick checks them all every frame; Zero-Tick wakes exactly when one of them is due.
A hybrid, not anti-ECS.
- Sparse things are event-driven: effects, cooldowns, respawns and input run on the timer queue and the inbox.
- Dense things are ECS-style periodic systems: AI think, movement and (planned) visibility iterate their components at their own cadence, inside the same pass and on the same writer thread.
- The data is ECS-style: state lives in components owned by systems, with data-oriented storage where the profiler points.
- Why not pure ECS: there is no public precedent of a classic MMO server core on pure ECS, about 310 thousand lines of scripts are written against object facades, and the measured gain needs data-oriented storage on a few hot paths only. See the architecture.
In ECS and tick engines, doing work is the default. In Zero-Tick it is the exception an event triggers.
Spaces
Section titled “Spaces”- The open world is one partition. It is not cut into regions: sieges load exactly the borders a split would create, and splitting a seamless world across writers is the approach that failed in practice.
- Each instance and each olympiad match is a partition of its own. They are separate spaces in the game already.
- Global services (clans, auction, castles and the siege schedule) are single writers too, each with its own inbox. Partitions send them commands and read their state from published snapshots.
Massive world battles don’t slow down the Olympiad. Every partition has its own loop thread, and there is no global barrier between partitions, so a siege that fills the world pass does not hold up a match. The condition is enough CPU cores.
Timer queue
Section titled “Timer queue”Timers live in two queues per partition (game time and wall-clock time): a JDK binary heap ordered by deadline, then registration order. The head is the earliest deadline, so an idle partition holding thousands of timers minutes ahead (respawns, despawns, long buffs) runs no pass until the first of them is due.
Cancelling a timer flags it; the flagged entry is skipped when it comes up, and when flagged entries are half the heap it is rebuilt without them. Entity timers also lapse by generation, so a cleanse or a death costs nothing per timer. Neither uses up the overdue cap below.
A repeating timer keeps its phase and never catches up: after a late pass it runs once and its next deadline is the
next point of its period grid. Timers that are overdue (their deadline is older than pass-min before the pass) run up
to overdue-per-pass per queue and pass; the rest keeps its place for the next pass. A timer armed while timers fire
never fires in the same pass: its deadline moves to at least 1 ms after the pass, behind everything already due, so a
handler re-arming itself can neither spin one pass nor starve the timers behind it. If the wall clock is set back, no
deadline fires early.
The queue replaced a hashed timer wheel (Agrona’s DeadlineTimerWheel with per-turn buckets). The wheel did not order
timers within a tick and could not tell its earliest deadline, so it carried buckets, a bitmap and a sort per tick; a
randomized differential test now runs the queue against a reference model on every build.
Input signal
Section titled “Input signal”- One ready signal per session. The producer enqueues, issues a full fence (
VarHandle.fullFence()) and posts “has input” only when the session’s flag was clear. The partition clears the flag once the queue is empty, issues a full fence and checks the queue again. With fences on both sides, an item offered while the flag is being cleared is never left in the queue unsignalled. - Strict arrival order per client, with round-robin across sessions and a per-pass limit, so a flooding client delays only itself.
- A failing item costs only itself. The item is taken off the queue and then run; a handler that throws is reported and the rest of the pass goes on.
Single writer
Section titled “Single writer”Every type that holds world state belongs to one thread: the loop thread of its partition. Such a type is marked
@SingleWriter, and each of its public methods starts with an ownership check: a call from any other thread throws,
counts a meter and logs the call site once a minute. Build rules keep it that way: ArchUnit requires the check first
in every public method of a marked type and forbids concurrent collections, locks, atomics, volatile and public
mutable fields there; PMD forbids synchronized, in the engine and in the scripts. A subclass of a marked type (a
script extending a monster) carries the mark too, and a test pins the short list of exceptions (static data such as
templates), which only shrinks.
- What is covered. The open world and its regions, every world object and creature down to monsters, pets, doors and service NPCs, effects and reuse, items and their containers, the player with its holders, stats and variables, parties, command channels and party matching, clans, zones, spawns, instances, residences and their functions, clan hall and item auctions: 252 types. Static data (templates, zone geometry) is built once and only read, and plain locations are value types handed out as copies.
- What it removed. In those classes: 119
synchronizedmethods and blocks, 155 concurrent collections, 43 atomics, 28volatilefields and 6 explicit locks, down to none. Their place is taken by plain fastutil and JDK collections with a fixed iteration order, so the same inputs pick the same targets, owners and drops. - Iteration. A type hands out read-only snapshots, never its live collection. A walk that calls out (packets, AI, effects, a caller’s callback) runs over a copy taken into reusable per-depth scratch buffers, so it allocates nothing in a steady state; hot callers get lookups and counts instead of copies.
- What it found. Moving each class onto one thread meant reading every path that touched it. The migration fixed, among others: any player could edit or dismiss any party room; a command channel stayed led by a party leader who had left; a passive skill replaced by a new level kept its old tick tasks; a buff-slot effect grew the buff limit on every use; clan hall, castle and fort functions kept charging fees after removal; zone settings were never restored on reload; the clan war list packet could announce more records than it sent.
| Path (JMH, ns per operation) | Before | Single writer |
|---|---|---|
| Stat calculation of a creature | 19 | 15 |
| Item lookup by object id in a container | 224 | 2.8 |
| Zone revalidation on a move (enter and leave) | 340 | 186 |
| Walk over 100 clan members | 315 | 258 |
| Online count of a 100-member clan | 317 | 190 |
| Region switch, 500 objects per region | 23 000 | 54 000 |
Medians of three interleaved JMH rounds against the build before each step, on an AMD Ryzen 5 5600X with JDK 25 on Windows 11. Most paths got faster because a plain collection is cheaper than a concurrent one. The check itself costs 1 to 5 ns per call: about 1 ns alone, about 5 ns inside the region switch; where a path makes thousands of checks and does little else, as in the region switch, it shows (#146).
Benchmarks
Section titled “Benchmarks”Measured on an AMD Ryzen 5 5600X, JDK 25, Linux in Docker on WSL2, which is a virtual machine; absolute numbers on bare metal differ, the ratios are what matters.
Cost per timer, scheduled and fired in a batch of one pass, ns per timer, with 1 000 / 100 000 / 1 000 000 other timers pending. The queue and the wheel it replaced were measured interleaved in one session on the same machine, round by round; this is the first round, which ran on a quiet machine:
| Scheduler | 1 000 | 100 000 | 1 000 000 |
|---|---|---|---|
| Zero-Tick timer queue | 85 | 134 | 169 |
| Zero-Tick timer queue, every second one cancelled | 89 | 142 | 174 |
| Timer wheel it replaced | 581 | 387 | 361 |
In a pass-sized batch the heap is not slower than the wheel at any size. The wheel figure at 1 000 pending carries an error of about ±1 050 ns; the second round of the wheel gave 374 / 351 / 659 ns. A second round of the queue ran under CPU load from other processes and is not comparable. Scheduling and firing one timer at a time is the heap’s weak case: it costs 73 / 262 / 3 143 ns against 78 / 224 / 1 143 ns of the wheel, since the sift over a million entries misses the cache. Cancelling half of a batch adds no visible cost.
Idle world: no player online, each NPC holds a respawn-like timer due about once a minute (30–90 s).
| Scheduler | Wake-ups/s, 1 000 NPCs | Wake-ups/s, 10 000 NPCs | CPU ms/s, 1 000 NPCs | CPU ms/s, 10 000 NPCs |
|---|---|---|---|---|
| Zero-Tick | 16 | 150 | 1.0 | 4.1 |
| L2J Mobius | 28 | 275 | 4.7 | 16.9 |
| 100 ms tick | 10 | 10 | 0.38 | 0.55 |
Zero-Tick wakes once per due timer. L2J Mobius runs the same timers and adds 11 fixed-rate AI and movement tasks per 1 000 NPCs every second, firing on empty pools; it already parks the AI of an NPC with no player in sight, which keeps that number small. A tick wakes 10 times a second regardless and is the cheapest when idle; it pays in latency.
Firing lateness of timers with spread deadlines, and CPU per fired timer:
| Scheduler | p50 | p99 | CPU per timer |
|---|---|---|---|
| Zero-Tick | 0.9 ms | 1.5 ms | 2.2 µs |
| L2J Mobius pool | 0.09 ms | 0.15 ms | 23.7 µs |
| 100 ms tick | 50 ms | 99 ms | 0.19 µs |
Zero-Tick fires 0.9 ms late at p50 against 50 ms on a 100 ms tick. The Mobius pool fires closer, about 0.1 ms, at about eleven times the CPU per timer; for a game, a millisecond is below perception.
The idle and lateness tables were measured on the previous build with the timer wheel. A rerun on the timer queue gave the same wake-ups per second (16 and 148) and a lateness of about 1.1 ms at p50 and 1.7 ms at p99.
How to run:
./gradlew :engine:core:jmhJarjava -jar engine/core/build/libs/engine-core-jmh.jar TimerComparisonBenchmarkjava -jar engine/core/build/libs/engine-core-jmh.jar IdleWakeupBenchmarkjava -cp engine/core/build/libs/engine-core-jmh.jar app.l2nx.engine.core.loop.LatenessHarnessLatenessHarness is a plain main class, not JMH: it reports a distribution of a value measured inside the run.
Run one scheduler per JVM with scheduler=ZERO_TICK, MOBIUS_STPE or TICK_100MS.
Detailed comparison
Section titled “Detailed comparison”| Family | Execution model | Polling periods | Locking | Metrics | Determinism |
|---|---|---|---|---|---|
| Zero-Tick | one event loop per partition, timer queue, single writer | none: wakes on input, deadline or due system | none on game state | Micrometer per pass and phase, Prometheus | seeded RNG, clock per pass |
| L2J Mobius | shared ScheduledThreadPoolExecutor (4 threads per CPU) plus an instant pool, fixed-rate task managers |
AI 1000 ms, movement 100 ms (creatures) and 50 ms (players), game time 10 ticks/s, decay, respawn and autosave 1000 ms | about 220 synchronized sites, region activity switching |
none; a deadlock watcher | ThreadLocalRandom, no seed, no replay |
| aCis | one scheduled pool (one thread per CPU) on virtual threads, a fixed-rate task per object | AI 1000 ms | about 150 synchronized sites |
none | none |
| L2J Server | four scheduled pools (effects, general, AI, events), a game-time thread | 10 ticks/s for movement, AI task per attackable 1000 ms | synchronized in about 110 files |
none | none |
| L2jFrozen-style Interlude forks | the L2J design: effects, general and AI pools plus packet pools | timer thread 10 ticks/s, AI 1000 ms | synchronized |
pool dumps, deadlock detector | none |
| L2-Scripts line | a scheduled pool (4 threads per CPU) plus a worker pool, a stepping AI task manager | AI step 250 ms, NPC AI delay 1000 ms | ReentrantLock and synchronized |
telnet status and pool dumps | none |
| L2E (Elixir) | a process per entity, send_after timers, message passing |
regeneration every 3 s | none: no shared state | none | :rand without a seed |
No public tree of these families exports Micrometer, Prometheus or OpenTelemetry metrics, and none logs a seed or replays a session.
Honest limits
Section titled “Honest limits”-
Cross-partition messages take one pass. A command from the world to a global service is handled in that service’s next pass, and its result in the world’s next pass after that. The order is deterministic, but it is not simultaneous.
-
An overloaded pass slows its own partition. A partition is one thread, so a heavy pass delays everything in it. One thread per partition has a big margin: a pass with 100 000 live timers takes about 170 µs against a 50 ms budget, and EVE Online runs one thread per solar system. If a siege needs more, these steps are taken in order and only on measurements:
- Move everything that does not change state off the world thread: the persistence executor, the flush on Netty threads, replication and the client wire into a separate edge gateway process (#4).
- Parallel pure computation inside a phase: pathfinding, line of sight and visibility diffs on worker threads, while writes stay on one thread (#21).
- Time dilation, like EVE’s TiDi: an over-budget partition slows its game time instead of falling out of sync (#20).
- As a last resort, regions of the open world, each with its own writer, as in Folia (#21). Borders are where dupes were born in other projects and where Lineage 2 concentrates load: sieges.
The signal is the per-phase pass time and the over-budget counter (
pass-budget); the acceptance test is bot sieges on the full stack. There are no real siege numbers yet: the world is still migrating. -
The ownership check costs 1 to 5 ns per call. Most hot paths came out faster anyway, but a path that makes thousands of checks and little else pays for them: a region switch over 500 objects per region runs about twice as long, 23 µs to 54 µs (#146, under the performance epic #151).
-
Every world entity is a single writer; the global services are not yet. Managers, data tables and task managers outside the entity model still keep concurrent collections of their own and are the next step (#161).
-
Open work:
-
The open world is a single writer on the world loop and all its game timers run on the loop API. AI think and movement already run as periodic systems, but the other imported Mobius subsystems still schedule their own work as fixed-rate tasks. The migration path shows the rest of the order: subsystems move to partition deadlines one at a time, and instances and olympiad matches get their own partitions.
Read more
Section titled “Read more”- Architecture: partitions, the entity model, replication, persistence and the migration path.
- Engine research: tick models, timer wheels and queues, actors and how mature MMO servers are built.
