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:
irrlicht
2026-09-18 23:00:19 +02:00
co-authored by Claude Opus 5
parent f594f12f35
commit 64a6e1d42d
6 changed files with 127 additions and 552 deletions
+4 -4
View File
@@ -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<GameAction>`. All key bindings live here.
@@ -31,7 +31,7 @@ Translates a `pbio::Key` to an `Option<GameAction>`. 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`:
+1 -1
View File
@@ -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")`.
+43 -65
View File
@@ -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).
+6 -6
View File
@@ -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`.
-324
View File
@@ -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.
+73 -152
View File
@@ -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>