118 lines
4.3 KiB
Markdown
Executable File
118 lines
4.3 KiB
Markdown
Executable File
# 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).
|