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
+73
-152
@@ -1,129 +1,61 @@
|
||||
# Roadmap
|
||||
|
||||
Workspace layout: `shared/` (wire types), `server/` (authoritative sim), `client/` (render
|
||||
terminal). The platform layer — window, GPU, input, RGB332 palette — lives in the external
|
||||
`pbio` crate (git dependency), not in this repo.
|
||||
Local single-player roguelike with a continuously simulated, tick-based world.
|
||||
|
||||
Workspace layout: `sim/` (headless world simulation — tiles, entities, movement rules,
|
||||
tick loop; no platform dependencies, unit-testable) and `game/` (the executable — window,
|
||||
rendering, input, pathfinding, camera; owns a `Sim` and drives it). The platform layer —
|
||||
window, GPU, input, RGB332 palette — lives in the external `pbio` crate (git dependency),
|
||||
not in this repo.
|
||||
|
||||
History: this fork started from a client/server multiplayer architecture (authoritative UDP
|
||||
server, thin client with prediction). The scope was cut to single player in September 2026;
|
||||
the sim kept its authoritative shape, everything network-related was removed. The `sim`
|
||||
crate's `tick`/`set_action` interface is the seam a server could be wrapped around again.
|
||||
|
||||
---
|
||||
|
||||
## Achieved milestones
|
||||
|
||||
- [x] TGA loader: `Image`, `from_tga`, `to_tileset`, `decode_rle` in `client/src/assets.rs`
|
||||
- [x] Pixel helpers: `set_pixel`, `extract`, `blit`, `blit_tile` in `client/src/game/pixelhelper.rs`
|
||||
- [x] TGA loader: `Image`, `from_tga`, `to_tileset`, `decode_rle` in `game/src/assets.rs`
|
||||
- [x] Pixel helpers: `set_pixel`, `extract`, `blit`, `blit_tile` in `game/src/game/pixelhelper.rs`
|
||||
— signed `i32` offsets, clip against all four framebuffer edges, index 0 = transparent
|
||||
- [x] Input system: `GameAction`, `InputMap`, `InputState` in `client/src/input.rs` (over `pbio::Key`)
|
||||
- [x] Protocol spec: `notes/protocol.md` — Header, State, Action, Chunk, Entity, EntityQuery,
|
||||
EntityDetail, Ping/Pong; reliability model
|
||||
- [x] Shared crate: packet structs as `bytemuck::Pod` with compile-time size assertions,
|
||||
`player_action` / `packet_type` constants, `chunk_id` / `chunk_coords`
|
||||
- [x] Server binary: 24 Hz tick loop, 3×3-chunk test world (`build_world`), wall collision,
|
||||
per-client action queue, `ChunkPacket` dispatch on cache miss, stale-client eviction
|
||||
- [x] Client net: `NetClient` in `client/src/net.rs`, non-blocking UDP recv,
|
||||
`StatePacket` / `EntityPacket` / `ChunkPacket` / `PongPacket` dispatch, entity-checksum retransmit
|
||||
- [x] End-to-end loop: player moves on server, position reflected in `EntityPacket`, rendered on client
|
||||
- [x] Tile rendering: chunk-based world rendering from received `ChunkPacket` data (6-bit unpack)
|
||||
- [x] Tick-based movement: movement resolved in `entity_tick`, currently every 4th tick (`sim.rs`)
|
||||
- [x] Sprite-based entity rendering: entity `type_id` → tile in `entities.tga`, blitted with transparency
|
||||
- [x] Phase-staggered broadcast: per-client `send_phase`, flat outbound load across ticks
|
||||
- [x] Ping/Pong RTT measurement (`PingPacket` / `PongPacket`)
|
||||
- [x] Connection timeout: client emits `Disconnected` after 10 s without a `StatePacket`;
|
||||
server evicts clients unseen for 10 s
|
||||
- [x] Map loader: Tiled-CSV parser in `server/src/map.rs` (flip-flag masking, content-derived
|
||||
- [x] Input system: `GameAction`, `InputMap`, `InputState` in `game/src/input.rs` (over `pbio::Key`)
|
||||
- [x] World model: `World` of 32×32 palette-compressed `Chunk`s (64-entry `TileDef` palette,
|
||||
6-bit tile indices), entities indexed per chunk, `entities_in_viewport`
|
||||
- [x] Map loader: Tiled-CSV parser in `sim/src/map.rs` (flip-flag masking, content-derived
|
||||
dimensions), `load_world` slices it into chunks, invisible solid border at the map rim.
|
||||
Replaces the hardcoded `build_world`. See `notes/07-map-loader.md`
|
||||
- [x] Client movement prediction: queued unconfirmed steps (`path` in `client/src/game.rs`),
|
||||
paced at the server's 6 Hz movement cadence, reconciled against `EntityPacket` positions,
|
||||
dropped after idle timeout. Shared collision vocabulary `shared::tile_collidable`
|
||||
(currently empty — every tile walkable) keeps client prediction and server sim in lockstep.
|
||||
- [x] Click-to-move: framebuffer click → world tile, A* over the chunk cache
|
||||
(`client/src/game/pathfind.rs`, unknown chunks count as blocked), route translated into
|
||||
cardinal actions one step per movement interval — the server only ever sees movement
|
||||
actions and stays authoritative. Keyboard input cancels the route; each step is
|
||||
re-validated at send time and a blocked step voids the route. In-flight steps render
|
||||
bright blue, planned route dim blue. Click-and-hold steers continuously: while the
|
||||
button is held the route keeps replanning toward the tile under the cursor (only
|
||||
when that tile changes — cursor or camera movement), sweeping across blocked tiles
|
||||
keeps the current route, and a voided route replans automatically while held.
|
||||
See `notes/07-map-loader.md`
|
||||
- [x] Tile rendering: chunk-based world rendering straight from `World` (`render_viewport`)
|
||||
- [x] Sprite-based entity rendering: entity `type_id` → tile in `entities.tga`, blitted with transparency
|
||||
- [x] Hybrid tick loop: 24 Hz base tick, movement in `entity_tick` every 4th tick (6 Hz);
|
||||
the game loop drives the sim with a fixed-timestep accumulator. See `notes/06-hybrid-tick-loop.md`
|
||||
- [x] Intent model: one scheduled action per entity per movement window (`Sim::set_action`),
|
||||
replaced on re-set, consumed on execution. Headless tests in `sim/src/sim.rs`
|
||||
- [x] 8-directional movement in chessboard geometry: world physics use the Chebyshev
|
||||
metric — diagonal and cardinal steps are the same distance, a "circle" is a square
|
||||
of tiles, matching the square viewport. Four diagonal actions in
|
||||
`shared::player_action`; the single-step rule lives in `shared::step_allowed`
|
||||
(king move onto a free tile, diagonals additionally need both orthogonal neighbors
|
||||
free — no corner cutting) and is the one function used by the server sim, client
|
||||
send-time validation and the client A* (8-connected, Chebyshev heuristic). Two held
|
||||
keys walk diagonally.
|
||||
- [x] Collision vocabulary seeded with test values (`shared::tile_collidable`): id 146
|
||||
(trees/rocks) and id 0 — id 0 doubles as the server's invisible world border, which
|
||||
the client previously mispredicted as walkable. A proper tile-data file format
|
||||
replaces this table later.
|
||||
- [x] Tick-addressed action scheduling (supersedes two interim designs — a sequence-
|
||||
deduped FIFO queue and its flow control — that fixed a periodic walking hitch and
|
||||
a path/route deadlock but kept two free-running clocks racing each other). Actions
|
||||
are now scheduled onto the server's tick timeline: `ActionPacket.target_tick`
|
||||
(formerly `sequence`) selects the movement window, a second action to the same
|
||||
window *replaces* the first (retraction via NOOP, rescheduling, retransmit dedup),
|
||||
late actions fill only an *empty* next window (gap-filling without overriding
|
||||
newer intent — needed over real internet links so actions don't die pointlessly),
|
||||
and only `ACTION_WINDOW_HORIZON = 3` future windows are addressable. The sim keeps
|
||||
per-entity window slot maps and executes at most one action per window, so floods
|
||||
can neither grow memory nor speed anyone up. The client estimates the server tick
|
||||
from `StatePacket.tick` plus elapsed time and schedules each step into the next
|
||||
window — one send per window by construction, no local send timer, no clock-rate
|
||||
race. `target_tick = 0` marks keep-alive/cache-ack packets with no scheduling
|
||||
intent. See `notes/protocol.md` (ActionPacket).
|
||||
- [x] Client-side unexpected-state handling: the movement goal is persistent (outlives
|
||||
the planned route) and every surprise reroutes toward it — a blocked route step
|
||||
replans instead of voiding the plan, and a confirmed position off the predicted
|
||||
path (lost/rejected/overridden step) retracts all still-scheduled steps (NOOP to
|
||||
their windows), drops the stale prediction and replans from the confirmed tile.
|
||||
Changing plans mid-run (new click/steer target) likewise retracts scheduled-but-
|
||||
unexecuted steps, so old intent stops playing out on the server within a window.
|
||||
The goal is released on arrival, unreachability, keyboard override, or a discrete
|
||||
click on an unreachable tile. This is the "reactive replanning" item formerly
|
||||
parked under Later — Robustness.
|
||||
- [x] `netsim` — bad-internet simulator (workspace member, dev tool): a UDP proxy adding
|
||||
delay, jitter and loss per direction (`--delay/--jitter/--loss`, `--up-*`/`--down-*`
|
||||
overrides; reordering emerges from jitter). No root, game-traffic only, zero deps.
|
||||
The client takes an optional server address argument to point at it:
|
||||
`cargo run -p netsim -- --delay 80 --jitter 30 --loss 5` + `client 127.0.0.1:7778`.
|
||||
- [x] Bad-link hardening (netsim immediately broke the naive scheduling — locks under
|
||||
isolated loss 15% and isolated delay 120 ms):
|
||||
(a) RTT-adaptive scheduling lead — the client pings automatically (1 Hz, smoothed),
|
||||
and schedules `ceil((rtt + margin) / window)` windows ahead instead of always one:
|
||||
under systematic latency "late" had been the *normal* case. The retraction horizon
|
||||
moves out the same way (a cancellation needs the same lead an action does).
|
||||
(b) Late-rule cleanup in the sim — a late movement action keeps its *order*, not
|
||||
its time (first still-empty upcoming window), so bunched late arrivals no longer
|
||||
collapse onto one slot and eat each other; late NOOPs are dropped outright (as
|
||||
gap-fillers they used to block real steps: retract → late NOOPs poison upcoming
|
||||
windows → replanned steps eaten → retract again — a lock loop).
|
||||
(c) Client stall watchdog — steps in flight but nothing confirmed for 750 ms means
|
||||
the prediction is dead no matter why (e.g. *all* in-flight actions lost: the server
|
||||
never moves, so the moved-off-plan desync detection never fires, path stays full,
|
||||
nothing is ever sent again): retract, drop, replan toward the goal.
|
||||
`PATH_MAX_LEN` is back to 8 as a pure prediction bound — server safety now comes
|
||||
from window addressing, and on a slow link several correct steps are legitimately
|
||||
unconfirmed at once (confirmations lag a full RTT).
|
||||
- [x] Prediction rebuilt as predict → ack → replay (replaces the per-problem patches
|
||||
above with structural robustness; fixed multi-second replan storms at high ping).
|
||||
The old model stored absolute predicted tiles and reconciled by tile matching, so
|
||||
any surprise "invalidated" the whole prediction and recovery meant clear + replan
|
||||
from the confirmed position — but steps inside the retraction horizon cannot be
|
||||
cancelled and still execute ("zombies"), shifting the server off every fresh plan
|
||||
and re-triggering recovery in a loop. Now: pending steps are `(window, delta)`
|
||||
pairs, `EntityPacket.tick` is the acknowledgment cursor (every window ≤ tick/4 is
|
||||
provably consumed — executed, rejected or lost, it no longer matters which), and
|
||||
the predicted position is always *derived* by replaying pending deltas on top of
|
||||
the confirmed position. A surprise shifts the prediction instead of killing it; a
|
||||
route that no longer connects triggers one clean replan toward the persistent goal
|
||||
via the existing send-time validation. Deleted outright: tile-matching reconcile,
|
||||
the moved-off-plan desync heuristic, the stall watchdog, and the idle path drop —
|
||||
acked windows expire pending steps automatically, so the path cannot go stale.
|
||||
The replay applies each pending delta through the shared `step_allowed` rule —
|
||||
exactly as the server will — so a delta the server is going to reject does not
|
||||
move the prediction either, and the predicted position can never sit inside a
|
||||
wall (previously a diverged prediction could, causing a brief walk-into-wall
|
||||
lock until the acks caught up).
|
||||
of tiles, matching the square viewport. The single-step rule lives in
|
||||
`sim::step_allowed` (king move onto a free tile, diagonals additionally need both
|
||||
orthogonal neighbors free — no corner cutting) and is the one function used by the
|
||||
sim and the A*. Two held keys walk diagonally.
|
||||
- [x] Collision vocabulary seeded with test values (`sim::tile_collidable`): id 146
|
||||
(trees/rocks) and id 0 (empty / the invisible world border). A proper tile-data file
|
||||
format replaces this table later.
|
||||
- [x] Click-to-move: framebuffer click → world tile, A* over the world
|
||||
(`game/src/game/pathfind.rs`, 8-connected, Chebyshev heuristic), route fed into the
|
||||
sim one step per movement window. Keyboard input cancels the route; each step is
|
||||
re-validated at scheduling time and a blocked step replans toward the persistent
|
||||
goal. Click-and-hold steers continuously: while the button is held the route keeps
|
||||
replanning toward the tile under the cursor (only when that tile changes — cursor
|
||||
or camera movement), sweeping across blocked tiles keeps the current route.
|
||||
- [x] Smooth camera follow: the camera is a float pixel position (`cam` in `game.rs`)
|
||||
that tracks the player linearly at ~64 px/s, snapping only to whole pixels at
|
||||
render time (and outright on teleport-sized corrections). The viewport renders
|
||||
with sub-tile offsets (31×31 tile pass + right-strip clip).
|
||||
- [x] Entity interpolation: per-id lerp table (`EntityLerp` in `game.rs`) — previous/current
|
||||
tile plus a clock, rendered as a pixel lerp over one movement interval. Jumps of
|
||||
more than one tile (Chebyshev) snap. Purely cosmetic; game logic keeps using the
|
||||
sim's tile positions.
|
||||
|
||||
---
|
||||
|
||||
@@ -131,13 +63,10 @@ terminal). The platform layer — window, GPU, input, RGB332 palette — lives i
|
||||
|
||||
### 06 — Hybrid tick loop *(partially done)*
|
||||
|
||||
Design of record: **`notes/06-hybrid-tick-loop.md`** (24 Hz base tick + stride scheduling).
|
||||
This supersedes the original 20/10/1 Hz three-tier sketch.
|
||||
Design of record: **`notes/06-hybrid-tick-loop.md`**.
|
||||
|
||||
- [x] 24 Hz base loop (`server/src/main.rs`)
|
||||
- [x] Phase-staggered broadcast at 12 Hz/client (`send_phase` in `server/src/net.rs`)
|
||||
- [x] Stride layout settled: `entity_tick` at `%4` (6 Hz, sets movement rate), broadcast at
|
||||
`%2` (12 Hz/client). The `06` note now documents this and why.
|
||||
- [x] 24 Hz base loop driven from the game loop
|
||||
- [x] Stride layout settled: `entity_tick` at `%4` (6 Hz, sets movement rate)
|
||||
- [ ] Action-point model (`energy` / `speed` per entity, act when `energy >= ACTION_COST`).
|
||||
Removed from `entity.rs` for now as premature — reintroduce when NPCs (08) actually need it.
|
||||
Then `entity_tick` can move to `%2` (12 Hz) and `speed` sets the effective movement rate.
|
||||
@@ -148,66 +77,61 @@ This supersedes the original 20/10/1 Hz three-tier sketch.
|
||||
|
||||
Remaining follow-ups when the need is concrete:
|
||||
|
||||
- Collision vocabulary: seeded with test values (0, 146) in `shared::tile_collidable`.
|
||||
- Collision vocabulary: seeded with test values (0, 146) in `sim::tile_collidable`.
|
||||
Decide on a proper file format for tile data (collision, opacity, …) instead of a
|
||||
hardcoded match, then feed both server and client from it.
|
||||
hardcoded match.
|
||||
- Tile flipping: orientation is discarded on load; revisit with the sprite pass (09).
|
||||
- Multiple / object layers (spawns, triggers) — not yet parsed.
|
||||
- Multiple / object layers (spawns, triggers) — not yet parsed. The player currently
|
||||
spawns at (0, 0).
|
||||
|
||||
---
|
||||
|
||||
### 08 — Basic NPC entity + AI budget
|
||||
|
||||
One dumb wandering enemy. Validates the simulation architecture before complexity accumulates.
|
||||
Depends on the action-point model from 06 being reintroduced.
|
||||
Depends on the action-point model from 06 being reintroduced. NPCs schedule intents through
|
||||
the same `Sim::set_action` the player uses.
|
||||
|
||||
- Add `EntityKind::Npc` with a `think() -> u32` method returning budget cost
|
||||
- Per sim-tick: distribute `think_budget = BASE / (clients + 1)` across entities ordered by player proximity
|
||||
- Per sim-tick: distribute a fixed `think_budget` across entities ordered by player proximity
|
||||
- Complex entities consume more budget; simple ones less. Loop breaks at zero — natural load shedding.
|
||||
- No framework. No trait objects yet. A match on `EntityKind` is fine.
|
||||
|
||||
```rust
|
||||
let mut budget: u32 = BASE_BUDGET / (client_count + 1).max(1);
|
||||
let mut budget: u32 = BASE_BUDGET;
|
||||
for entity in entities_by_player_proximity() {
|
||||
if budget == 0 { break; }
|
||||
budget = budget.saturating_sub(entity.think(&mut world));
|
||||
}
|
||||
```
|
||||
|
||||
- Entity-vs-entity blocking: `step_allowed` only checks tiles. Once NPCs exist, occupied
|
||||
tiles need to block too — in the sim *and* in the pathfinder's `blocked` closure.
|
||||
|
||||
---
|
||||
|
||||
### 09 — Client camera *(camera follow done, entity interpolation open)*
|
||||
### 09 — Client camera *(done — see Achieved)*
|
||||
|
||||
Sprite rendering already landed (see Achieved). Camera status:
|
||||
|
||||
- [x] Smooth camera follow: the camera is a float pixel position (`cam` in `game.rs`)
|
||||
that tracks the player linearly at ~64 px/s, snapping only to whole pixels at
|
||||
render time (and outright on teleport-sized corrections). The viewport renders
|
||||
with sub-tile offsets (31×31 tile pass + right-strip clip).
|
||||
- [x] Entity interpolation: per-id lerp table (`EntityLerp` in `game.rs`) beside the
|
||||
entity list — previous/current tile plus a clock, rendered as a pixel lerp over
|
||||
one movement interval. Jumps of more than one tile (Chebyshev) snap. Purely
|
||||
cosmetic; game logic keeps using authoritative tile positions.
|
||||
Sprite rendering, camera follow and entity interpolation have all landed.
|
||||
|
||||
---
|
||||
|
||||
### Later — UI / HUD
|
||||
|
||||
- Bitmap font renderer: 8×8 glyph sheet TGA, `draw_text(frame, font, x, y, color, text)` — see `notes/04`
|
||||
- UI primitives in `client/src/ui.rs`: `fill_rect`, `draw_rect`, `draw_hbar` — see `notes/05`
|
||||
- UI primitives in `game/src/ui.rs`: `fill_rect`, `draw_rect`, `draw_hbar` — see `notes/05`
|
||||
- HUD layout (320×240): HP bar + player name bottom 16px, message log right panel
|
||||
- A real clip-rect on the blit primitives belongs here — once the viewport and HUD panels are
|
||||
two distinct regions, the abstraction earns its keep. Not before.
|
||||
|
||||
---
|
||||
|
||||
### Later — Robustness + auth
|
||||
### Later — Distribution
|
||||
|
||||
- Auth token handshake: replace source-address identity (`auth_token` field exists but is unused)
|
||||
- Per-address rate limiting for the action ingest (only meaningful once identity is real —
|
||||
address spoofing bypasses any limit before then)
|
||||
- Multi-datagram `EntityPacket`: server currently truncates at 66 entities (`net.rs` TODO)
|
||||
- Asset embedding: `include_bytes!` for single-binary distribution
|
||||
- Asset embedding: `include_bytes!` for single-binary distribution (assets are currently
|
||||
read relative to the working directory)
|
||||
- Save / load: the world is a plain `Sim` value — serialize it. Roguelike convention is a
|
||||
single save slot deleted on load
|
||||
|
||||
---
|
||||
|
||||
@@ -215,11 +139,8 @@ Sprite rendering already landed (see Achieved). Camera status:
|
||||
|
||||
Once the simulation architecture is stable and a real level exists:
|
||||
|
||||
- Doors, interactive objects (server-authoritative state)
|
||||
- Combat: melee range check, HP, death, respawn
|
||||
- Inventory: on-demand via `EntityQueryPacket` / `EntityDetailPacket`
|
||||
- Day/night and weather on the slow tick — affect visibility, spawns
|
||||
- Doors, interactive objects
|
||||
- Combat: melee range check, HP, death, permadeath
|
||||
- Inventory
|
||||
- Day/night and weather on the slow tick (`% 192`) — affect visibility, spawns
|
||||
- Procedural dungeon generation (BSP or cellular automata feeding into the same tile format)
|
||||
|
||||
---
|
||||
</content>
|
||||
|
||||
Reference in New Issue
Block a user