# 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: ```rust // 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: ```rust // 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).