Files
forgotten_caves/notes/roadmap.md
T
2026-09-18 22:41:20 +02:00

226 lines
14 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
---
## 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`
— 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
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.
- [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).
---
## Upcoming work
### 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.
- [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.
- [ ] 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.
---
### 07 — Tiled map loader *(done — see Achieved / `notes/07-map-loader.md`)*
Remaining follow-ups when the need is concrete:
- Collision vocabulary: seeded with test values (0, 146) in `shared::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.
- Tile flipping: orientation is discarded on load; revisit with the sprite pass (09).
- Multiple / object layers (spawns, triggers) — not yet parsed.
---
### 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.
- 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
- 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);
for entity in entities_by_player_proximity() {
if budget == 0 { break; }
budget = budget.saturating_sub(entity.think(&mut world));
}
```
---
### 09 — Client camera *(camera follow done, entity interpolation open)*
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.
---
### 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`
- 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
- 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
---
### Later — World depth
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
- Procedural dungeon generation (BSP or cellular automata feeding into the same tile format)
---
</content>