Resumed to project. INIT
This commit is contained in:
Executable
+117
@@ -0,0 +1,117 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user