Allocating a Server Tick Budget Per Entity

A 20 TPS server gives the game loop 50ms per tick. That budget is shared, not per system: every entity’s AI, the physics step, network serialisation, and any synchronous I/O draw from the same 50ms. When entity count doubles, the systems do not double equally, and knowing which one breaks first determines whether you optimise, shard, or buy faster cores.

At 60 TPS the budget is 16.6ms, and at 64 TPS it is 15.6ms. That is tight enough that a single system taking 6ms leaves the loop permanently at risk. Budgeting at 85% of the tick — 42.5ms at 20 TPS, 14ms at 64 TPS — leaves room for GC, OS scheduling, and the occasional long frame you cannot profile.

Measure the split before you change anything. Instrument each system with a monotonic clock at entry and exit, accumulate per tick, and dump the p50 and p99 per system every 60 seconds. On a dedicated box with cores pinned and the scheduler quiet, the numbers become stable enough to act on, which is the real reason to run game servers on dedicated hardware rather than a shared host.

A working allocation for 42.5ms

The split below is what a mid-weight survival or sandbox server tends to settle at with 200 active entities and 60 players. It is a starting budget, not a target to defend.

SystemBudget (20 TPS)Budget (64 TPS)Scaling
Entity AI / behaviour trees18ms6msLinear per entity
Physics / collision9ms3msSuperlinear (broadphase)
Network serialisation6ms2msLinear per player, ~constant per entity
World / chunk updates5ms1.5msBursty
I/O, saves, plugin hooks4.5ms1.5msSpiky

Per-entity cost, measured

Divide the AI budget by active entity count and you get a ceiling. At 200 entities and 18ms, that is 90µs per entity per tick. A simple wander-and-chase mob costs 15–30µs; a mob with pathfinding, target selection, and line-of-sight checks costs 40–120µs. Fewer than 10% of entities typically need the expensive branch.

Entity typeCost/tickDominant cost
Idle / decorative3–8µsTimer update
Wandering mob15–30µsMovement + collision
Pursuing mob (A*)60–150µsPath recompute
Player entity40–80µsInput + serialisation
Projectile5–12µsRaycast
Redstone / logic block10–40µsNeighbour propagation

What degrades first when entity count doubles

Physics, not AI. Broadphase collision is O(n log n) at best and O(n²) when entities clump, and clumping is exactly what happens in a busy base or a boss fight. AI doubles linearly; collision can quadruple. The second thing to break is network serialisation, because per-entity state broadcasts to every nearby player, so cost scales with entities × observers.

I/O is the wildcard. If autosave or plugin hooks run synchronously inside the tick, a 300ms save stalls the whole loop regardless of entity count. Move saves to an async thread and you remove the most common cause of one-off 20-second TPS drops.

Entity chunking

Partition the world into fixed cells — 16×16 blocks horizontally, or 8–16m in a 3D world — and tick cells that contain active entities, sleeping the rest. A cell with only idle entities costs 1–3µs per tick to check. In practice this removes 60–75% of AI cost, because most of a large world is empty at any moment.

  • Cell size: one chunk or 16m; smaller cells add bookkeeping overhead
  • Wake conditions: player proximity, damage, scheduled event, redstone signal
  • Sleeping cells skip AI and pathfinding but still run cheap timer updates
  • Re-wake cost: budget 200–500µs for a cell’s first tick after waking

AI throttling

Not every entity needs a decision at 20Hz. Run expensive AI on a rotating schedule: full pathfinding every 4th tick for distant mobs, every 8th for idle ones, and every tick only for entities in combat. Distant entities can interpolate movement instead of recomputing it. This alone typically cuts AI from 18ms to 9–11ms with no visible behaviour change.

Combine with an entity budget: cap the number of entities processed for AI per tick at, say, 400, and carry the remainder to the next tick with a fair queue. The loop stays inside budget and behaviour degrades gracefully instead of the tick stretching.

Network serialisation is not free

Serialising 200 entities for 60 players is 12,000 potential state writes per tick if you send everything to everyone. Interest management — sending only entities within view distance or a 32–64m radius — cuts that by 85–95%. Delta compression on top of that turns most per-tick updates into a few bytes.

Measure serialised bytes per tick, not just CPU time. Past about 2MB/tick across all players, the NIC and the event loop become the limit and no amount of CPU optimisation helps.

A cheap win: move serialisation onto a worker thread and hand the event loop a ready byte buffer. The tick then spends 1–2ms preparing update lists instead of 6ms packing packets, and the network thread absorbs the cost across cores that were otherwise idle.

Profile, budget, then hardware

Write the budget into the profiler output: a tick that exceeds 42.5ms logs which system went over. Fix the largest overshoot, re-measure, repeat. Hardware only enters the picture when single-system cost remains high after algorithm changes — at that point a higher all-core clock buys the most per dollar for a single-threaded loop.

Budget per entity, tick in cells, throttle distant AI, and the tick stays flat as counts grow. When the loop is still tight at 400 entities, upgrade to a box with the clock speed to match.

Leave a Reply