Files
forgotten_caves/notes/06-hybrid-tick-loop.md
T
2026-06-17 08:23:13 +02:00

4.3 KiB
Executable File
Raw Blame History

Hybrid Tick Loop

Single 24 Hz base tick; subsystems self-select frequency via stride scheduling. Replaces the old 3-tier (20/10/1 Hz buckets) description from roadmap item 06.


Stride scheduling

All subsystems hang off one monotonic tick: u32 counter. Each subsystem fires when its stride condition is met. Current implementation:

tick % 1   == 0  → input processing, inbound recv                          (main.rs)
tick % 2   == send_phase → broadcast state to clients, phase-staggered     (net.rs, 12 Hz/client)
tick % 4   == 0  → entity_tick() — movement / collision                    (sim.rs, 6 Hz)
tick % 8   == 0  → (reserved) slower AI routines, pathfinding refresh
tick % 192 == 0  → (reserved) world simulation (8-sec cycle: weather, daylight, respawns)

24 Hz is highly composite (divisors: 1, 2, 3, 4, 6, 8, 12, 24), giving flexible stride options without resorting to co-prime tricks. tick >> 3 yields 3 Hz pulses; world-sim at % 192 is exactly an 8-second cycle.

Why entity_tick runs at 6 Hz, not 12 Hz: without the action-point model (below), one entity_tick = one tile of movement, so the tick rate is the movement rate. 6 Hz yields the target ~6 tiles/sec. Once energy/speed are reintroduced, entity_tick can move up to 12 Hz (% 2) and per-entity speed sets the effective movement rate instead — decoupling sim rate from movement rate. The broadcast rate (12 Hz/client) is deliberately higher than the movement rate: it keeps position latency low and adds redundancy against packet loss.


Action-point model (entity AI)

Status: not yet implemented. Removed from entity.rs as premature (no NPCs need it yet). Until it returns, entity_tick runs at 6 Hz to set the movement rate directly (see above). Reintroduce alongside roadmap item 08 (NPCs).

Layered on top of entity_tick(). Replaces fixed per-entity cooldown timers.

Each entity carries:

  • energy: i32 — accumulates each tick
  • speed: u8 — added to energy every entity_tick()

An entity acts when energy >= ACTION_COST, then pays the cost:

// inside entity_tick()
entity.energy += entity.speed as i32;
if entity.energy >= ACTION_COST {
    entity.act(&mut world);
    entity.energy -= ACTION_COST;
}

Fast entities (speed >= ACTION_COST) act every tick. Slow entities act every N ticks naturally without any scheduler involvement. Haste and slow effects become simple speed modifiers — no special-case scheduling needed.


Simulation rate vs network rate

Sim and net rates are independent. The current targets:

Layer Rate Stride
Base loop 24 Hz every tick
Network broadcast (per client, phase-staggered) 12 Hz tick % 2 == send_phase
Entity tick (movement / collision) 6 Hz tick % 4 == 0

Clients receive a fresh EntityPacket every ~83 ms. For tile-based movement at 5–6 tiles/sec this is sufficient with comfortable headroom.

Rough bandwidth per client at 12 Hz:

  • StatePacket 72 B × 12 = 864 B/s
  • EntityPacket ~1200 B × 12 = 14 400 B/s
  • Total: ~15 KB/s outbound per client

Tick-offset broadcasting (phase staggering)

Rather than flushing all clients on the same tick, assign each client a send_phase at connection time and send only when the client's phase matches:

// ClientState gains:
send_phase: u8,  // assigned as entity_id % BROADCAST_STRIDE at creation

// broadcast() skips clients whose phase doesn't match current tick:
if tick % BROADCAST_STRIDE as u32 != cs.send_phase as u32 { continue; }

Benefits:

  • Outbound NIC load is flat across ticks instead of spiking every 2nd tick
  • Scales with player count without architectural changes
  • Each client still receives state at the same effective rate

Why higher tick rate doesn't help high-ping players

Perceived latency ≈ RTT + tick_processing_delay + state_interval

LTE @ 100ms RTT, 24Hz sim, 12Hz net:
  worst case: 100 + 42 + 83 = 225ms   ← RTT-dominated

Same at 64Hz sim, 20Hz net:
  worst case: 100 + 15 + 50 = 165ms   ← 60ms gain, negligible for roguelike

RTT is the dominant term. Higher tick rates yield diminishing returns and increase server CPU load for minimal perceived benefit. 24 Hz is sufficient for deliberate tile-based input and remains acceptable at LTE latencies (~100ms RTT) and remote locations (~200ms RTT).