Rewrite notes for the single-player architecture
Drop protocol.md; rewrite the tick-loop note around the game-loop-driven sim and the intent model; rewrite the roadmap (network items removed, sim/game crate layout, save/load and NPC blocking added); fix crate paths in the remaining notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f594f12f35
commit
64a6e1d42d
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Current state
|
## 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
|
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
|
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;
|
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
|
## Architecture
|
||||||
|
|
||||||
### `GameAction` — `client/src/input.rs`
|
### `GameAction` — `game/src/input.rs`
|
||||||
|
|
||||||
Logical actions the game cares about. Game code never sees backend key types.
|
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 }
|
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.
|
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 |
|
| Enter | Confirm |
|
||||||
| Escape | Cancel |
|
| Escape | Cancel |
|
||||||
|
|
||||||
### `InputState` — `client/src/input.rs`
|
### `InputState` — `game/src/input.rs`
|
||||||
|
|
||||||
Three internal buffers, populated from `pbio::Event::Key` events in `main.rs`:
|
Three internal buffers, populated from `pbio::Event::Key` events in `main.rs`:
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Current state
|
## 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
|
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
|
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")`.
|
module. `game.rs` uses it directly: `Image::from_tga("assets/tilesets/overworld.tga")`.
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
# Hybrid Tick Loop
|
# Hybrid Tick Loop
|
||||||
|
|
||||||
Single 24 Hz base tick; subsystems self-select frequency via stride scheduling. Replaces the old
|
Single 24 Hz base tick; subsystems self-select frequency via stride scheduling. The sim is
|
||||||
3-tier (20/10/1 Hz buckets) description from roadmap item 06.
|
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
|
## 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:**
|
stride condition is met. **Current implementation:**
|
||||||
|
|
||||||
```
|
```
|
||||||
tick % 1 == 0 → input processing, inbound recv (main.rs)
|
tick % 1 == 0 → (game loop) input processing, intent scheduling
|
||||||
tick % 2 == send_phase → broadcast state to clients, phase-staggered (net.rs, 12 Hz/client)
|
|
||||||
tick % 4 == 0 → entity_tick() — movement / collision (sim.rs, 6 Hz)
|
tick % 4 == 0 → entity_tick() — movement / collision (sim.rs, 6 Hz)
|
||||||
tick % 8 == 0 → (reserved) slower AI routines, pathfinding refresh
|
tick % 8 == 0 → (reserved) slower AI routines, pathfinding refresh
|
||||||
tick % 192 == 0 → (reserved) world simulation (8-sec cycle: weather, daylight, respawns)
|
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
|
`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
|
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
|
(`% 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
|
from movement rate.
|
||||||
rate: it keeps position latency low and adds redundancy against packet loss.
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
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`
|
naturally without any scheduler involvement. Haste and slow effects become simple `speed`
|
||||||
modifiers — no special-case scheduling needed.
|
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).
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Map Loader
|
# Map Loader
|
||||||
|
|
||||||
Loads level data from a Tiled CSV export, replacing the old hardcoded `build_world` wall loop.
|
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
|
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
|
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
|
now** — flipped tiles render unflipped. Real flipping would need a flip-aware `blit_tile` plus
|
||||||
the client plus the flags carried through the protocol; deferred to the camera/sprite pass (09).
|
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:
|
Tile ids are used directly:
|
||||||
|
|
||||||
- **Server** — `tile_flags(id)` (in `map.rs`) maps an id to gameplay `TileFlags`
|
- **Sim** — `tile_flags(id)` (in `map.rs`) maps an id to gameplay `TileFlags`
|
||||||
(collidable / opaque). Hand-maintained vocabulary; currently everything is walkable floor.
|
(collidable / opaque) via the `sim::tile_collidable` vocabulary. Hand-maintained; test values only.
|
||||||
- **Client** — the id indexes straight into `overworld.tga` (`game.rs`). The tileset's tile
|
- **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.
|
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`.
|
No `firstgid` subtraction: the authored ids already line up with `overworld.tga`.
|
||||||
|
|||||||
@@ -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
@@ -1,129 +1,61 @@
|
|||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
Workspace layout: `shared/` (wire types), `server/` (authoritative sim), `client/` (render
|
Local single-player roguelike with a continuously simulated, tick-based world.
|
||||||
terminal). The platform layer — window, GPU, input, RGB332 palette — lives in the external
|
|
||||||
`pbio` crate (git dependency), not in this repo.
|
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
|
## Achieved milestones
|
||||||
|
|
||||||
- [x] TGA loader: `Image`, `from_tga`, `to_tileset`, `decode_rle` in `client/src/assets.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 `client/src/game/pixelhelper.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
|
— 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] Input system: `GameAction`, `InputMap`, `InputState` in `game/src/input.rs` (over `pbio::Key`)
|
||||||
- [x] Protocol spec: `notes/protocol.md` — Header, State, Action, Chunk, Entity, EntityQuery,
|
- [x] World model: `World` of 32×32 palette-compressed `Chunk`s (64-entry `TileDef` palette,
|
||||||
EntityDetail, Ping/Pong; reliability model
|
6-bit tile indices), entities indexed per chunk, `entities_in_viewport`
|
||||||
- [x] Shared crate: packet structs as `bytemuck::Pod` with compile-time size assertions,
|
- [x] Map loader: Tiled-CSV parser in `sim/src/map.rs` (flip-flag masking, content-derived
|
||||||
`player_action` / `packet_type` constants, `chunk_id` / `chunk_coords`
|
|
||||||
- [x] Server binary: 24 Hz tick loop, 3×3-chunk test world (`build_world`), wall collision,
|
|
||||||
per-client action queue, `ChunkPacket` dispatch on cache miss, stale-client eviction
|
|
||||||
- [x] Client net: `NetClient` in `client/src/net.rs`, non-blocking UDP recv,
|
|
||||||
`StatePacket` / `EntityPacket` / `ChunkPacket` / `PongPacket` dispatch, entity-checksum retransmit
|
|
||||||
- [x] End-to-end loop: player moves on server, position reflected in `EntityPacket`, rendered on client
|
|
||||||
- [x] Tile rendering: chunk-based world rendering from received `ChunkPacket` data (6-bit unpack)
|
|
||||||
- [x] Tick-based movement: movement resolved in `entity_tick`, currently every 4th tick (`sim.rs`)
|
|
||||||
- [x] Sprite-based entity rendering: entity `type_id` → tile in `entities.tga`, blitted with transparency
|
|
||||||
- [x] Phase-staggered broadcast: per-client `send_phase`, flat outbound load across ticks
|
|
||||||
- [x] Ping/Pong RTT measurement (`PingPacket` / `PongPacket`)
|
|
||||||
- [x] Connection timeout: client emits `Disconnected` after 10 s without a `StatePacket`;
|
|
||||||
server evicts clients unseen for 10 s
|
|
||||||
- [x] Map loader: Tiled-CSV parser in `server/src/map.rs` (flip-flag masking, content-derived
|
|
||||||
dimensions), `load_world` slices it into chunks, invisible solid border at the map rim.
|
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`
|
See `notes/07-map-loader.md`
|
||||||
- [x] Client movement prediction: queued unconfirmed steps (`path` in `client/src/game.rs`),
|
- [x] Tile rendering: chunk-based world rendering straight from `World` (`render_viewport`)
|
||||||
paced at the server's 6 Hz movement cadence, reconciled against `EntityPacket` positions,
|
- [x] Sprite-based entity rendering: entity `type_id` → tile in `entities.tga`, blitted with transparency
|
||||||
dropped after idle timeout. Shared collision vocabulary `shared::tile_collidable`
|
- [x] Hybrid tick loop: 24 Hz base tick, movement in `entity_tick` every 4th tick (6 Hz);
|
||||||
(currently empty — every tile walkable) keeps client prediction and server sim in lockstep.
|
the game loop drives the sim with a fixed-timestep accumulator. See `notes/06-hybrid-tick-loop.md`
|
||||||
- [x] Click-to-move: framebuffer click → world tile, A* over the chunk cache
|
- [x] Intent model: one scheduled action per entity per movement window (`Sim::set_action`),
|
||||||
(`client/src/game/pathfind.rs`, unknown chunks count as blocked), route translated into
|
replaced on re-set, consumed on execution. Headless tests in `sim/src/sim.rs`
|
||||||
cardinal actions one step per movement interval — the server only ever sees movement
|
|
||||||
actions and stays authoritative. Keyboard input cancels the route; each step is
|
|
||||||
re-validated at send time and a blocked step voids the route. In-flight steps render
|
|
||||||
bright blue, planned route dim blue. Click-and-hold steers continuously: while the
|
|
||||||
button is held the route keeps replanning toward the tile under the cursor (only
|
|
||||||
when that tile changes — cursor or camera movement), sweeping across blocked tiles
|
|
||||||
keeps the current route, and a voided route replans automatically while held.
|
|
||||||
- [x] 8-directional movement in chessboard geometry: world physics use the Chebyshev
|
- [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
|
metric — diagonal and cardinal steps are the same distance, a "circle" is a square
|
||||||
of tiles, matching the square viewport. Four diagonal actions in
|
of tiles, matching the square viewport. The single-step rule lives in
|
||||||
`shared::player_action`; the single-step rule lives in `shared::step_allowed`
|
`sim::step_allowed` (king move onto a free tile, diagonals additionally need both
|
||||||
(king move onto a free tile, diagonals additionally need both orthogonal neighbors
|
orthogonal neighbors free — no corner cutting) and is the one function used by the
|
||||||
free — no corner cutting) and is the one function used by the server sim, client
|
sim and the A*. Two held keys walk diagonally.
|
||||||
send-time validation and the client A* (8-connected, Chebyshev heuristic). Two held
|
- [x] Collision vocabulary seeded with test values (`sim::tile_collidable`): id 146
|
||||||
keys walk diagonally.
|
(trees/rocks) and id 0 (empty / the invisible world border). A proper tile-data file
|
||||||
- [x] Collision vocabulary seeded with test values (`shared::tile_collidable`): id 146
|
format replaces this table later.
|
||||||
(trees/rocks) and id 0 — id 0 doubles as the server's invisible world border, which
|
- [x] Click-to-move: framebuffer click → world tile, A* over the world
|
||||||
the client previously mispredicted as walkable. A proper tile-data file format
|
(`game/src/game/pathfind.rs`, 8-connected, Chebyshev heuristic), route fed into the
|
||||||
replaces this table later.
|
sim one step per movement window. Keyboard input cancels the route; each step is
|
||||||
- [x] Tick-addressed action scheduling (supersedes two interim designs — a sequence-
|
re-validated at scheduling time and a blocked step replans toward the persistent
|
||||||
deduped FIFO queue and its flow control — that fixed a periodic walking hitch and
|
goal. Click-and-hold steers continuously: while the button is held the route keeps
|
||||||
a path/route deadlock but kept two free-running clocks racing each other). Actions
|
replanning toward the tile under the cursor (only when that tile changes — cursor
|
||||||
are now scheduled onto the server's tick timeline: `ActionPacket.target_tick`
|
or camera movement), sweeping across blocked tiles keeps the current route.
|
||||||
(formerly `sequence`) selects the movement window, a second action to the same
|
- [x] Smooth camera follow: the camera is a float pixel position (`cam` in `game.rs`)
|
||||||
window *replaces* the first (retraction via NOOP, rescheduling, retransmit dedup),
|
that tracks the player linearly at ~64 px/s, snapping only to whole pixels at
|
||||||
late actions fill only an *empty* next window (gap-filling without overriding
|
render time (and outright on teleport-sized corrections). The viewport renders
|
||||||
newer intent — needed over real internet links so actions don't die pointlessly),
|
with sub-tile offsets (31×31 tile pass + right-strip clip).
|
||||||
and only `ACTION_WINDOW_HORIZON = 3` future windows are addressable. The sim keeps
|
- [x] Entity interpolation: per-id lerp table (`EntityLerp` in `game.rs`) — previous/current
|
||||||
per-entity window slot maps and executes at most one action per window, so floods
|
tile plus a clock, rendered as a pixel lerp over one movement interval. Jumps of
|
||||||
can neither grow memory nor speed anyone up. The client estimates the server tick
|
more than one tile (Chebyshev) snap. Purely cosmetic; game logic keeps using the
|
||||||
from `StatePacket.tick` plus elapsed time and schedules each step into the next
|
sim's tile positions.
|
||||||
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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -131,13 +63,10 @@ terminal). The platform layer — window, GPU, input, RGB332 palette — lives i
|
|||||||
|
|
||||||
### 06 — Hybrid tick loop *(partially done)*
|
### 06 — Hybrid tick loop *(partially done)*
|
||||||
|
|
||||||
Design of record: **`notes/06-hybrid-tick-loop.md`** (24 Hz base tick + stride scheduling).
|
Design of record: **`notes/06-hybrid-tick-loop.md`**.
|
||||||
This supersedes the original 20/10/1 Hz three-tier sketch.
|
|
||||||
|
|
||||||
- [x] 24 Hz base loop (`server/src/main.rs`)
|
- [x] 24 Hz base loop driven from the game loop
|
||||||
- [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)
|
||||||
- [x] Stride layout settled: `entity_tick` at `%4` (6 Hz, sets movement rate), broadcast at
|
|
||||||
`%2` (12 Hz/client). The `06` note now documents this and why.
|
|
||||||
- [ ] Action-point model (`energy` / `speed` per entity, act when `energy >= ACTION_COST`).
|
- [ ] 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.
|
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.
|
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:
|
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
|
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).
|
- 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
|
### 08 — Basic NPC entity + AI budget
|
||||||
|
|
||||||
One dumb wandering enemy. Validates the simulation architecture before complexity accumulates.
|
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
|
- 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.
|
- 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.
|
- No framework. No trait objects yet. A match on `EntityKind` is fine.
|
||||||
|
|
||||||
```rust
|
```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() {
|
for entity in entities_by_player_proximity() {
|
||||||
if budget == 0 { break; }
|
if budget == 0 { break; }
|
||||||
budget = budget.saturating_sub(entity.think(&mut world));
|
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:
|
Sprite rendering, camera follow and entity interpolation have all landed.
|
||||||
|
|
||||||
- [x] Smooth camera follow: the camera is a float pixel position (`cam` in `game.rs`)
|
|
||||||
that tracks the player linearly at ~64 px/s, snapping only to whole pixels at
|
|
||||||
render time (and outright on teleport-sized corrections). The viewport renders
|
|
||||||
with sub-tile offsets (31×31 tile pass + right-strip clip).
|
|
||||||
- [x] Entity interpolation: per-id lerp table (`EntityLerp` in `game.rs`) beside the
|
|
||||||
entity list — previous/current tile plus a clock, rendered as a pixel lerp over
|
|
||||||
one movement interval. Jumps of more than one tile (Chebyshev) snap. Purely
|
|
||||||
cosmetic; game logic keeps using authoritative tile positions.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Later — UI / HUD
|
### Later — UI / HUD
|
||||||
|
|
||||||
- Bitmap font renderer: 8×8 glyph sheet TGA, `draw_text(frame, font, x, y, color, text)` — see `notes/04`
|
- 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
|
- 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
|
- 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.
|
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)
|
- Asset embedding: `include_bytes!` for single-binary distribution (assets are currently
|
||||||
- Per-address rate limiting for the action ingest (only meaningful once identity is real —
|
read relative to the working directory)
|
||||||
address spoofing bypasses any limit before then)
|
- Save / load: the world is a plain `Sim` value — serialize it. Roguelike convention is a
|
||||||
- Multi-datagram `EntityPacket`: server currently truncates at 66 entities (`net.rs` TODO)
|
single save slot deleted on load
|
||||||
- Asset embedding: `include_bytes!` for single-binary distribution
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -215,11 +139,8 @@ Sprite rendering already landed (see Achieved). Camera status:
|
|||||||
|
|
||||||
Once the simulation architecture is stable and a real level exists:
|
Once the simulation architecture is stable and a real level exists:
|
||||||
|
|
||||||
- Doors, interactive objects (server-authoritative state)
|
- Doors, interactive objects
|
||||||
- Combat: melee range check, HP, death, respawn
|
- Combat: melee range check, HP, death, permadeath
|
||||||
- Inventory: on-demand via `EntityQueryPacket` / `EntityDetailPacket`
|
- Inventory
|
||||||
- Day/night and weather on the slow tick — affect visibility, spawns
|
- 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)
|
- Procedural dungeon generation (BSP or cellular automata feeding into the same tile format)
|
||||||
|
|
||||||
---
|
|
||||||
</content>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user