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:
co-authored by
Claude Opus 5
parent
f594f12f35
commit
64a6e1d42d
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user