From 64a6e1d42db2497318a9c5e446d1bb88ae4568b4 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Fri, 18 Sep 2026 23:00:19 +0200 Subject: [PATCH] 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 --- notes/01-input_manager.md | 8 +- notes/02-asset_loader.md | 2 +- notes/06-hybrid-tick-loop.md | 108 +++++------- notes/07-map-loader.md | 12 +- notes/protocol.md | 324 ----------------------------------- notes/roadmap.md | 225 ++++++++---------------- 6 files changed, 127 insertions(+), 552 deletions(-) delete mode 100755 notes/protocol.md diff --git a/notes/01-input_manager.md b/notes/01-input_manager.md index e29cf39..5079e78 100755 --- a/notes/01-input_manager.md +++ b/notes/01-input_manager.md @@ -2,7 +2,7 @@ ## Current state -Input is fully decoupled from the windowing backend. `client/src/input.rs` owns all input +Input is fully decoupled from the windowing backend. `game/src/input.rs` owns all input types. `main.rs` translates `pbio::Event::Key` events into `GameAction` values each frame and stores them in an `InputState`. `game::update()` receives a `&InputState` and queries it with three distinct semantics. (The platform/windowing layer lives in the external `pbio` crate; @@ -10,7 +10,7 @@ game code only ever sees `pbio::Key`, never a raw backend type.) ## Architecture -### `GameAction` — `client/src/input.rs` +### `GameAction` — `game/src/input.rs` Logical actions the game cares about. Game code never sees backend key types. @@ -18,7 +18,7 @@ Logical actions the game cares about. Game code never sees backend key types. pub enum GameAction { Up, Down, Left, Right, Confirm, Cancel } ``` -### `InputMap` — `client/src/input.rs` +### `InputMap` — `game/src/input.rs` Translates a `pbio::Key` to an `Option`. All key bindings live here. @@ -31,7 +31,7 @@ Translates a `pbio::Key` to an `Option`. All key bindings live here. | Enter | Confirm | | Escape | Cancel | -### `InputState` — `client/src/input.rs` +### `InputState` — `game/src/input.rs` Three internal buffers, populated from `pbio::Event::Key` events in `main.rs`: diff --git a/notes/02-asset_loader.md b/notes/02-asset_loader.md index cacfd75..86beac9 100755 --- a/notes/02-asset_loader.md +++ b/notes/02-asset_loader.md @@ -2,7 +2,7 @@ ## Current state -TGA loading is implemented in `client/src/assets.rs`. `Image::from_tga(path)` reads the file, +TGA loading is implemented in `game/src/assets.rs`. `Image::from_tga(path)` reads the file, parses the 18-byte header, skips the colormap block, and returns an `Image` — supporting type 1 (uncompressed) and type 9 (RLE) via the private `decode_rle()` function in the same module. `game.rs` uses it directly: `Image::from_tga("assets/tilesets/overworld.tga")`. diff --git a/notes/06-hybrid-tick-loop.md b/notes/06-hybrid-tick-loop.md index 5ae1d38..7ace5f2 100755 --- a/notes/06-hybrid-tick-loop.md +++ b/notes/06-hybrid-tick-loop.md @@ -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). diff --git a/notes/07-map-loader.md b/notes/07-map-loader.md index 9b65971..267e303 100644 --- a/notes/07-map-loader.md +++ b/notes/07-map-loader.md @@ -1,7 +1,7 @@ # Map Loader Loads level data from a Tiled CSV export, replacing the old hardcoded `build_world` wall loop. -Implemented in `server/src/map.rs`; wired into the world in `server/src/main.rs::load_world`. +Implemented in `sim/src/map.rs`; wired into the world in `sim/src/lib.rs::load_world`. --- @@ -37,8 +37,8 @@ therefore shows up in the CSV as a large *negative* decimal when read as `i32`. The loader parses each field as `i64`, reinterprets the low 32 bits as `u32`, and strips the flip flags with `& 0x1FFF_FFFF`, leaving the bare tile id. **Flip orientation is discarded for -now** — flipped tiles render unflipped. Real flipping would need a flip-aware `blit_tile` on -the client plus the flags carried through the protocol; deferred to the camera/sprite pass (09). +now** — flipped tiles render unflipped. Real flipping would need a flip-aware `blit_tile` plus +the flags carried through the chunk palette; deferred to the camera/sprite pass (09). --- @@ -46,9 +46,9 @@ the client plus the flags carried through the protocol; deferred to the camera/s Tile ids are used directly: -- **Server** — `tile_flags(id)` (in `map.rs`) maps an id to gameplay `TileFlags` - (collidable / opaque). Hand-maintained vocabulary; currently everything is walkable floor. -- **Client** — the id indexes straight into `overworld.tga` (`game.rs`). The tileset's tile +- **Sim** — `tile_flags(id)` (in `map.rs`) maps an id to gameplay `TileFlags` + (collidable / opaque) via the `sim::tile_collidable` vocabulary. Hand-maintained; test values only. +- **Renderer** — the id indexes straight into `overworld.tga` (`game.rs`). The tileset's tile order *is* the id space; keep the Tiled tileset and `overworld.tga` in lockstep. No `firstgid` subtraction: the authored ids already line up with `overworld.tga`. diff --git a/notes/protocol.md b/notes/protocol.md deleted file mode 100755 index f59d8d3..0000000 --- a/notes/protocol.md +++ /dev/null @@ -1,324 +0,0 @@ -# Protocol -Wire format for client-server communication. - -## Transport - -- UDP (`std::net::UdpSocket`, non-blocking) -- Hard limit: **1200 bytes per datagram** (safe internet MTU; avoids IP fragmentation) -- All integers: little-endian -- Server streams `StatePackets` continuously; client sends `ActionPackets` only when needed - ---- - -## Header - -Every datagram begins with a 6-byte universal header. - -| Offset | Size | Type | Field | Notes | -|--------|------|------|---------------|-----------------------------------------| -| 0 | 2 | u16 | `magic` | Fixed: `0x524C` ("RL") — rejects noise | -| 2 | 1 | u8 | `version` | Protocol version — server rejects mismatch | -| 3 | 1 | u8 | `packet_type` | See packet type table below | -| 4 | 2 | u16 | `client_type` | 0 = official client; others registered | - -`client_type` allows the server to distinguish official clients, registered bots, and -forks for analytics and access control, without affecting the protocol logic. - ---- - -## Packet types - -| Packet type | id | Flow | Size | -|---------------|----|------------------|------------------| -| State | 0 | server → client | 72 bytes | -| Action | 1 | client → server | min. 74 bytes | -| Chunk Data | 2 | server → client | max. 909 bytes | -| Entity | 3 | server → client | max. 1200 bytes | -| Entity Query | 4 | client → server | 18 bytes | -| Entity Detail | 5 | server → client | max. 1200 bytes | -| Ping | 6 | client → server | 14 bytes | -| Pong | 7 | server → client | 14 bytes | - ---- - -### StatePacket — server → client - -Sent by the server at 10 Hz regardless of client activity. -Currently carries the chunk manifest for the 3×3 neighbourhood around the player. - -| Offset | Size | Type | Field | Notes | -|--------|------|------------|---------------------|-------------------------------------------------| -| 0 | 6 | Header | `header` | packet_type = 0 | -| 6 | 4 | u32 | `tick` | Server tick counter | -| 10 | 54 | ChunkEntry | `chunks[9]` | 9 chunk entries | -| 64 | 4 | u32 | `player_entity_id` | Global entity ID of the receiving client | -| 68 | 4 | u32 | `entity_checksum` | FNV-1a over all EntityPacket payloads this tick | - -**Total: 72 bytes** - -The `entity_checksum` lets the client detect a lost `EntityPacket` without a dedicated -ACK: if the checksum differs from the one computed over the last received entity update, -the client knows to retransmit an `ActionPacket` (`target_tick = 0`, no-op action) to -prompt the server to re-send the current entity state. - ---- - -### ActionPacket — client → server - -Sent by the client on player action or on a chunk cache miss. - -| Offset | Size | Type | Field | Notes | -|--------|------|--------------|-----------------|--------------------------------| -| 0 | 6 | Header | `header` | packet_type = 1 | -| 6 | 8 | u64 | `auth_token` | Token of the current session | -| 14 | 4 | u32 | `target_tick` | Tick the action is scheduled for (see below); 0 = keep-alive/ack only | -| 18 | 54 | ChunkEntry | `cache[9]` | Versions client currently holds | -| 72 | 2 | PlayerAction | `player_action` | Derived from user input | -| 74 | ? | ActionData | `action_data` | Dependent on PlayerAction | - -**Minimum: 74 bytes** (no ActionData) - -**Tick-addressed scheduling.** Actions are scheduled onto the server's tick timeline -instead of being consumed in arrival order. `target_tick` selects the movement window -(`target_tick / TICKS_PER_MOVE`, rounded up) the action executes in: - -- A second action addressed to the same window **replaces** the first — this is how the - client retracts (NOOP) or changes a scheduled step until its window executes, and how - retransmits dedupe for free. -- A **late** action (window already passed on arrival) moves to the next window, but - only if that slot is empty: late actions fill gaps, they never override newer intent. -- Only the next `ACTION_WINDOW_HORIZON` windows are addressable; anything beyond is - dropped. Combined with one-action-per-window execution this bounds server memory and - movement speed regardless of client behavior. -- `target_tick = 0` carries no scheduling intent (keep-alive / cache-ack packets). - -**ChunkEntry (6 bytes)** - -| Offset | Size | Type | Field | -|--------|------|------|------------| -| 0 | 4 | u32 | `chunk_id` | -| 4 | 2 | u16 | `version` | - -`chunk_id` encodes the chunk grid position: `(x as u16) | ((y as u16) << 16)`, -where x and y are signed chunk coordinates (i16 each). - -**PlayerAction (u16)** - -| Value | Action | -|-------|---------| -| 0 | No-Op (cache update only) | -| 1 | North | -| 2 | East | -| 3 | South | -| 4 | West | - -$TODO — additional actions (interact, etc.) - ---- - -### ChunkPacket — server → client - -Sent by the server for each chunk the client is missing or has stale. -One chunk per datagram. - -The world is divided into **32×32 tile chunks**. -- At most **4 chunks (2×2)** are visible at once. -- The 3×3 neighbourhood (9 chunks) covers all prefetch needs. -- Each chunk carries a **local palette** of up to 64 tile types. -- Tiles are encoded as 6-bit palette indices (4 tiles per 3 bytes, no padding). - -| Offset | Size | Type | Field | Notes | -|-------------------|----------|--------------|-------------|---------------------------------| -| 0 | 6 | Header | `header` | packet_type = 2 | -| 6 | 6 | ChunkEntry | `chunk` | ID and version of this chunk | -| 12 | 1 | u8 | `pal_count` | Number of palette entries (≤64) | -| 13 | max. 128 | u16[] | `palette` | Global tile IDs, pal_count × 2 | -| 13 + pal_count×2 | 768 | packed u6[] | `tiles` | 1024 tiles, 6-bit indices | - -**Maximum: 6 + 6 + 1 + 128 + 768 = 909 bytes** - -**Palette** — up to 64 entries, each a global tile ID (u16) mapping local 6-bit index → world tile type. - -**Tiles** — 1024 tiles packed as 256 groups of 3 bytes (4 tiles × 6 bits = 24 bits per group). - ---- - -### EntityPacket — server → client - -Sent by the server each tick, immediately after the `StatePacket`. -Contains the bulk entity update for all entities within the visible **30×30 tile viewport**. -Positions are **absolute world tile coordinates**. - -If the entity count exceeds what fits in one datagram, the server sends multiple -`EntityPacket`s on the same tick; `packet_flags` bit 0 signals that more follow. - -| Offset | Size | Type | Field | Notes | -|--------|----------------|-------------|----------------|--------------------------------| -| 0 | 6 | Header | `header` | packet_type = 3 | -| 6 | 4 | u32 | `tick` | Matches the `StatePacket` tick | -| 10 | 1 | u8 | `entity_count` | Entities in this datagram | -| 11 | 1 | u8 | `packet_flags` | bit 0: more packets follow | -| 12 | entity_count×… | EntityEntry | `entities` | Variable-length entries | - -**Maximum: 1200 bytes** — without metadata: ⌊(1200 − 12) / 18⌋ = **66 entities per datagram**. - ---- - -#### EntityEntry - -| Offset | Size | Type | Field | Notes | -|--------|------------|------|-----------------|-----------------------------------------| -| 0 | 4 | u32 | `id` | Server-assigned entity ID (persistent) | -| 4 | 2 | u16 | `type_id` | Entity type; 0 = player | -| 6 | 2 | i16 | `pos_x` | Absolute world tile X | -| 8 | 2 | i16 | `pos_y` | Absolute world tile Y | -| 10 | 2 | u16 | `hp` | Current HP | -| 12 | 2 | u16 | `hp_max` | Max HP | -| 14 | 2 | u16 | `elo` | Elo rating (acts as level) | -| 16 | 1 | u8 | `entity_flags` | See flag table below | -| 17 | 1 | u8 | `meta_len` | Byte length of the TLV metadata block | -| 18 | `meta_len` | u8[] | `meta` | TLV metadata (0 bytes if none) | - -**Base size: 18 bytes** (plus `meta_len` bytes of metadata). - -**entity_flags:** - -| Bit | Meaning | -|-----|-------------------------------------------------------| -| 0 | `is_static` — does not move (chest, sign, item stack) | -| 1 | `is_hostile` | -| 2 | `is_interactable` — show interaction prompt | -| 3–7 | reserved | - ---- - -#### Inline metadata — TLV format - -Inline metadata is a sequence of TLV (type–length–value) fields appended directly after -the fixed `EntityEntry` fields. `meta_len = 0` means no metadata is present. - -Each TLV field: - -| Offset | Size | Type | Field | -|--------|------|------|---------| -| 0 | 1 | u8 | `tag` | -| 1 | 1 | u8 | `len` | -| 2 | len | u8[] | `value` | - -**Defined tags:** - -| Tag | Name | Value format | Notes | -|------|------------------|--------------|--------------------------------| -| 0x01 | `status_effects` | u16 bitmask | Active status effects | -| 0x02 | `weapon` | u16 type_id | Currently equipped weapon | -| 0x03 | `armor` | u16 type_id | Currently equipped armor | - -Large metadata (chest inventory, sign text, dialogue trees) is not included inline. -Request it via `EntityQueryPacket` (type 4) on player interaction. - ---- - -### EntityQueryPacket — client → server - -Sent when the client needs detailed metadata for a specific entity (on interaction, -hover, or similar trigger). The server responds with an `EntityDetailPacket`. - -| Offset | Size | Type | Field | Notes | -|--------|------|--------|--------------|------------------------------| -| 0 | 6 | Header | `header` | packet_type = 4 | -| 6 | 8 | u64 | `auth_token` | Token of the current session | -| 14 | 4 | u32 | `entity_id` | Entity to query | - -**Total: 18 bytes** - ---- - -### EntityDetailPacket — server → client - -Full metadata response for a queried entity. Sent in reply to an `EntityQueryPacket`. - -| Offset | Size | Type | Field | Notes | -|--------|------------|--------|--------------|------------------------------| -| 0 | 6 | Header | `header` | packet_type = 5 | -| 6 | 4 | u32 | `tick` | Server tick at time of query | -| 10 | 4 | u32 | `entity_id` | Entity being described | -| 14 | 2 | u16 | `meta_len` | Byte length of metadata | -| 16 | `meta_len` | u8[] | `meta` | Full TLV metadata block | - -**Maximum: 1200 bytes** (up to 1184 bytes of metadata). - ---- - -### PingPacket / PongPacket — round-trip measurement - -Liveness and RTT probe. The client sends a `PingPacket`; the server echoes the payload back -unchanged as a `PongPacket`. Both share the same layout. - -| Offset | Size | Type | Field | Notes | -|--------|------|--------|----------------|--------------------------------------| -| 0 | 6 | Header | `header` | packet_type = 6 (Ping) / 7 (Pong) | -| 6 | 8 | u64 | `timestamp_ms` | Echoed verbatim by the server | - -**Total: 14 bytes** - -The client currently measures RTT locally via the elapsed time since the ping was sent, so -`timestamp_ms` is sent as 0; the field is reserved for server-stamped timing if needed later. - ---- - -## Protocol flow - -``` -── every tick (10 Hz) ──────────────────────────────────────────────────────── - -server → client StatePacket (tick N, chunk manifest, entity_checksum) -server → client EntityPacket (tick N, entities 0–73) -server → client EntityPacket (tick N, entities 74–N, more=0) ← if needed - - ↓ client verifies entity_checksum - ↓ checksum mismatch → retransmit ActionPacket (no-op) - ↓ chunk cache miss on chunk X → - -client → server ActionPacket (auth_token, seq, cache state, player_action) - -server → client ChunkPacket (chunk X, full data) -server → client ChunkPacket (chunk Y, full data) ← if multiple misses - -── on player interaction ───────────────────────────────────────────────────── - -client → server EntityQueryPacket (auth_token, entity_id) -server → client EntityDetailPacket (entity_id, full TLV metadata) -``` - -The server reads the `ActionPacket`'s cache list, computes the diff against current -chunk versions, and sends one `ChunkPacket` per missing or stale chunk. - ---- - -## Reliability - -UDP is unreliable. The protocol handles loss without a dedicated ACK mechanism: - -**Lost ActionPacket** — the server never learns of the cache miss and keeps streaming -StatePackets. The client retransmits the ActionPacket after **200–300 ms** if no -ChunkPacket has arrived. - -**Lost ChunkPacket** — the server has already processed the ActionPacket and will not -retransmit spontaneously. The client's timeout fires (200–300 ms), it resends the -ActionPacket with its updated cache state (listing only still-missing chunks), and -the server sends the missing chunks again. - -**Duplicate ActionPackets** — the server handles these idempotently. The cache list is -self-describing state; the server reads it, diffs against current versions, and sends -whatever is still missing. No deduplication logic is required. - -**Lost StatePacket** — the next tick delivers the same chunk manifest. No retry needed; -the client simply waits one tick (~100 ms). - -**Lost EntityPacket** — detected via the `entity_checksum` in the next `StatePacket`. -The client retransmits a no-op `ActionPacket` (`target_tick = 0`, no-op action, -current cache state); the server treats this as a normal diff request and re-sends the -full entity update for the tick. - -**Lost EntityQueryPacket / EntityDetailPacket** — the client retransmits the query -after a 200–300 ms timeout if no `EntityDetailPacket` has arrived. diff --git a/notes/roadmap.md b/notes/roadmap.md index 6fc6d03..e3fa937 100755 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -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) - ---- -