Rewrite notes for the single-player architecture

Drop protocol.md; rewrite the tick-loop note around the game-loop-driven sim
and the intent model; rewrite the roadmap (network items removed, sim/game
crate layout, save/load and NPC blocking added); fix crate paths in the
remaining notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irrlicht
2026-09-18 23:00:19 +02:00
co-authored by Claude Opus 5
parent f594f12f35
commit 64a6e1d42d
6 changed files with 127 additions and 552 deletions
+43 -65
View File
@@ -1,18 +1,41 @@
# 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.
Single 24 Hz base tick; subsystems self-select frequency via stride scheduling. The sim is
headless (`sim` crate) and advanced tick by tick from the game loop.
---
## Driving the sim from the render loop
Rendering runs at frame rate; the sim runs in whole ticks. `Game::update` accumulates frame
time and calls `Sim::step()` once per elapsed `TICK_MS` (`game/src/game.rs`):
```rust
self.tick_accum_ms += (dt as f32).min(MAX_FRAME_MS);
while self.tick_accum_ms >= TICK_MS {
self.tick_accum_ms -= TICK_MS;
if self.sim.step() { // true when a movement window executed
self.track_lerp(); // entity positions may have changed
self.sync_route(); // click-to-move bookkeeping
self.step_movement(input);// schedule the next step right away
}
}
```
`MAX_FRAME_MS` (250 ms) caps how much a single slow frame catches up, so a debugger pause or
window drag drops time instead of spiralling into a burst of ticks. Rendering interpolates
entity positions between tiles over one movement interval (`EntityLerp`), so the 6 Hz
movement cadence looks smooth at any frame rate.
---
## Stride scheduling
All subsystems hang off one monotonic `tick: u32` counter. Each subsystem fires when its
All subsystems hang off one monotonic `Sim::tick` 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 % 1 == 0 → (game loop) input processing, intent scheduling
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)
@@ -26,8 +49,21 @@ at `% 192` is exactly an 8-second cycle.
`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.
from movement rate.
---
## Intents: one action per entity per movement window
Actors do not act directly on the world; they schedule an intent (`Sim::set_action`). The
sim keeps exactly one intent per entity, setting it again replaces it, and executing the
movement window consumes it. So an actor moves at most one tile per window no matter how
often it changes its mind in between, and a tap shorter than a window still registers.
The player's intent is (re)set every frame from held keys or the click-to-move route; NPCs
will use the same call from their `think()` (roadmap 08). Validation goes through
`sim::step_allowed` on both sides — the pathfinder plans with the exact function the sim
executes, so a planned route never contains a step the sim will reject.
---
@@ -57,61 +93,3 @@ if 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).