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

14 KiB
Executable File
Raw Blame History

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

  • TGA loader: Image, from_tga, to_tileset, decode_rle in client/src/assets.rs
  • 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
  • Input system: GameAction, InputMap, InputState in client/src/input.rs (over pbio::Key)
  • Protocol spec: notes/protocol.md — Header, State, Action, Chunk, Entity, EntityQuery, EntityDetail, Ping/Pong; reliability model
  • Shared crate: packet structs as bytemuck::Pod with compile-time size assertions, player_action / packet_type constants, chunk_id / chunk_coords
  • 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
  • Client net: NetClient in client/src/net.rs, non-blocking UDP recv, StatePacket / EntityPacket / ChunkPacket / PongPacket dispatch, entity-checksum retransmit
  • End-to-end loop: player moves on server, position reflected in EntityPacket, rendered on client
  • Tile rendering: chunk-based world rendering from received ChunkPacket data (6-bit unpack)
  • Tick-based movement: movement resolved in entity_tick, currently every 4th tick (sim.rs)
  • Sprite-based entity rendering: entity type_id → tile in entities.tga, blitted with transparency
  • Phase-staggered broadcast: per-client send_phase, flat outbound load across ticks
  • Ping/Pong RTT measurement (PingPacket / PongPacket)
  • Connection timeout: client emits Disconnected after 10 s without a StatePacket; server evicts clients unseen for 10 s
  • 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
  • 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.
  • 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.
  • 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.
  • 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.
  • 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).
  • 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.
  • 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.
  • 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).
  • 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.

  • 24 Hz base loop (server/src/main.rs)
  • Phase-staggered broadcast at 12 Hz/client (send_phase in server/src/net.rs)
  • 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.
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:

  • 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).
  • 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)