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>
96 lines
3.9 KiB
Markdown
Executable File
96 lines
3.9 KiB
Markdown
Executable File
# Hybrid Tick Loop
|
|
|
|
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 `Sim::tick` counter. Each subsystem fires when its
|
|
stride condition is met. **Current implementation:**
|
|
|
|
```
|
|
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)
|
|
```
|
|
|
|
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.
|
|
|
|
---
|
|
|
|
## 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.
|
|
|
|
---
|
|
|
|
## 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.
|