Resumed to project. INIT
This commit is contained in:
Executable
+68
@@ -0,0 +1,68 @@
|
||||
# Input Manager
|
||||
|
||||
## Current state
|
||||
|
||||
Input is fully decoupled from the windowing backend. `client/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;
|
||||
game code only ever sees `pbio::Key`, never a raw backend type.)
|
||||
|
||||
## Architecture
|
||||
|
||||
### `GameAction` — `client/src/input.rs`
|
||||
|
||||
Logical actions the game cares about. Game code never sees backend key types.
|
||||
|
||||
```rust
|
||||
pub enum GameAction { Up, Down, Left, Right, Confirm, Cancel }
|
||||
```
|
||||
|
||||
### `InputMap` — `client/src/input.rs`
|
||||
|
||||
Translates a `pbio::Key` to an `Option<GameAction>`. All key bindings live here.
|
||||
|
||||
| Key | Action |
|
||||
|------------------|---------|
|
||||
| Up / W | Up |
|
||||
| Down / S | Down |
|
||||
| Left / A | Left |
|
||||
| Right / D | Right |
|
||||
| Enter | Confirm |
|
||||
| Escape | Cancel |
|
||||
|
||||
### `InputState` — `client/src/input.rs`
|
||||
|
||||
Three internal buffers, populated from `pbio::Event::Key` events in `main.rs`:
|
||||
|
||||
| Buffer | Lifetime | Populated by |
|
||||
|------------|----------------|---------------------------|
|
||||
| `pressed` | current frame | key-down event |
|
||||
| `held` | until released | key-down; cleared on up |
|
||||
| `released` | current frame | key-up event |
|
||||
|
||||
`clear()` is called after `game::update()` each frame. It clears `pressed` and `released`;
|
||||
`held` persists until the corresponding key-up event arrives.
|
||||
|
||||
#### Query API
|
||||
|
||||
```rust
|
||||
input.button_pressed(action) // true only on the frame the key went down
|
||||
input.button_held(action) // true every frame the key is physically held
|
||||
input.button_released(action) // true only on the frame the key was released
|
||||
```
|
||||
|
||||
## Data flow per frame
|
||||
|
||||
```
|
||||
pbio::Event::Key
|
||||
→ InputMap::translate(key)
|
||||
→ InputState::push / InputState::release
|
||||
→ game.update(&mut framebuffer, dt, &input_state) -> Option<GameSignal>
|
||||
→ input_state.clear()
|
||||
```
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should modifier keys (Shift, Ctrl) produce distinct actions, or be handled in the mapping?
|
||||
- How do we prepare for key rebinding?
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
# Asset Loader
|
||||
|
||||
## Current state
|
||||
|
||||
TGA loading is implemented in `client/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")`.
|
||||
|
||||
`Image` also provides `to_tileset() -> Vec<[u8; 64]>`, which splits the image into 8×8
|
||||
tiles row-major. Every tile is emitted; tile ID is the flat row-major index into the Vec,
|
||||
so tiles are never skipped or reordered. (Transparency is a per-pixel concern handled at
|
||||
blit time by `blit_tile`, not by the splitter.)
|
||||
|
||||
`AssetStore` (a named registry) is **not yet implemented**. Assets are currently loaded
|
||||
inline in `Game::start()`.
|
||||
|
||||
## Goal
|
||||
|
||||
Add an `AssetStore` so that game code can ask for an asset by name rather than loading
|
||||
files directly at the call site.
|
||||
|
||||
## Design notes
|
||||
|
||||
- `Image` struct: `width: u32`, `height: u32`, `pixels: Vec<u8>` — done.
|
||||
- A simple `AssetStore` could be a `HashMap<&'static str, Image>` loaded at startup.
|
||||
- No streaming needed — the whole game is small enough to load everything upfront in `start()`.
|
||||
- Palette index 0 is transparent when blitting sprites. This convention is enforced by
|
||||
`blit_tile()` in `pixelhelper.rs`; the loader itself does not need to handle it.
|
||||
- Pixel assets are stored as flat `Vec<u8>` of palette indices, row-major, width × height bytes.
|
||||
- Future: consider embedding assets with `include_bytes!` to produce a single binary.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should `AssetStore` hold pre-split tilesets too, or just raw `Image` values?
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
# Text Renderer
|
||||
|
||||
## Current state
|
||||
|
||||
No text rendering exists yet. No font loading or glyph blitting code exists.
|
||||
|
||||
## Goal
|
||||
|
||||
Draw ASCII text into the `u8` framebuffer using a bitmap font, respecting the palette-index
|
||||
color model (one u8 per pixel, no RGBA).
|
||||
|
||||
## Design notes
|
||||
|
||||
- A bitmap font is the right fit: each glyph is a small tile of palette indices, blitted
|
||||
directly into the framebuffer using the existing blit primitive in `pixelhelper.rs`.
|
||||
- Likely approach: a fixed 8×8 or similar glyph grid packed into a TGA or raw binary,
|
||||
one glyph per ASCII codepoint starting at 0x20 (space).
|
||||
- The font image itself should be indexed: foreground pixels carry a nonzero palette index
|
||||
(caller supplies the color), background pixels are index 0 (transparent/skip).
|
||||
- API sketch:
|
||||
```rust
|
||||
fn draw_text(frame: &mut [u8], font: &Font, x: i32, y: i32, color: u8, text: &str);
|
||||
```
|
||||
- `Font` holds the glyph sheet as an `Image` plus glyph width/height.
|
||||
|
||||
## Open questions
|
||||
|
||||
- How do we handle proportional spacing of different characters?
|
||||
- How do we store font data in memory?
|
||||
- How do we handle font color rendering?
|
||||
- How do we handle special characters like Ä, Ö, Ü, ß, etc.?
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
# UI Renderer
|
||||
|
||||
## Current state
|
||||
|
||||
No UI primitives exist. The framebuffer is currently filled by the game world only
|
||||
(`render_viewport` in `game.rs`: tile pass + entity sprite pass). `pixelhelper.rs` has
|
||||
low-level pixel/blit helpers but nothing higher-level.
|
||||
|
||||
## Goal
|
||||
|
||||
Draw common roguelike UI elements (panels, borders, HUD bars, etc.) procedurally into the
|
||||
`u8` framebuffer, composited on top of the game world.
|
||||
|
||||
## Design notes
|
||||
|
||||
The virtual resolution is 320×240, so UI layout should be designed in those pixel units.
|
||||
|
||||
Likely primitives needed (built on top of `pixelhelper.rs`):
|
||||
|
||||
- `fill_rect(frame, x, y, w, h, color)` — solid filled rectangle
|
||||
- `draw_rect(frame, x, y, w, h, color)` — 1-pixel border rectangle
|
||||
- `draw_border_box(frame, x, y, w, h, tileset)` — box drawn with corner/edge tiles from
|
||||
a tileset (classic roguelike panel look)
|
||||
- `draw_hbar(frame, x, y, w, value, max, fg, bg)` — horizontal progress/HP bar
|
||||
|
||||
Panels are typically fixed regions of the screen (e.g. a status bar at the bottom 40px,
|
||||
a message log on the right). Hard-coding these regions first is fine; extract to a layout
|
||||
system only if needed.
|
||||
|
||||
The UI layer draws after the world layer so it always appears on top. Draw order within
|
||||
the UI should be back-to-front (backgrounds before text).
|
||||
|
||||
## Open questions
|
||||
|
||||
- Tileset-based borders vs. line-drawing: which look are we going for?
|
||||
- Does the message log need scrolling? Probably not at first.
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## Stride scheduling
|
||||
|
||||
All subsystems hang off one monotonic `tick: u32` 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 % 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)
|
||||
```
|
||||
|
||||
24 Hz is highly composite (divisors: 1, 2, 3, 4, 6, 8, 12, 24), giving flexible stride
|
||||
options without resorting to co-prime tricks. `tick >> 3` yields 3 Hz pulses; world-sim
|
||||
at `% 192` is exactly an 8-second cycle.
|
||||
|
||||
**Why `entity_tick` runs at 6 Hz, not 12 Hz:** without the action-point model (below), one
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
## Action-point model (entity AI)
|
||||
|
||||
> **Status: not yet implemented.** Removed from `entity.rs` as premature (no NPCs need it yet).
|
||||
> Until it returns, `entity_tick` runs at 6 Hz to set the movement rate directly (see above).
|
||||
> Reintroduce alongside roadmap item 08 (NPCs).
|
||||
|
||||
Layered on top of `entity_tick()`. Replaces fixed per-entity cooldown timers.
|
||||
|
||||
Each entity carries:
|
||||
- `energy: i32` — accumulates each tick
|
||||
- `speed: u8` — added to energy every `entity_tick()`
|
||||
|
||||
An entity acts when `energy >= ACTION_COST`, then pays the cost:
|
||||
|
||||
```rust
|
||||
// inside entity_tick()
|
||||
entity.energy += entity.speed as i32;
|
||||
if entity.energy >= ACTION_COST {
|
||||
entity.act(&mut world);
|
||||
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).
|
||||
Executable
+310
@@ -0,0 +1,310 @@
|
||||
# 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` (sequence preserved, 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 | `sequence` | Monotonically increasing |
|
||||
| 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)
|
||||
|
||||
**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` (same sequence number, 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.
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
# Roadmap
|
||||
|
||||
Workspace layout: `shared/` (wire types), `server/` (authoritative sim), `client/` (render
|
||||
terminal). The platform layer — window, GPU, input, RGB332 palette — lives in the external
|
||||
`pbio` crate (git dependency), not in this repo.
|
||||
|
||||
---
|
||||
|
||||
## Achieved milestones
|
||||
|
||||
- [x] TGA loader: `Image`, `from_tga`, `to_tileset`, `decode_rle` in `client/src/assets.rs`
|
||||
- [x] Pixel helpers: `set_pixel`, `extract`, `blit`, `blit_tile` in `client/src/game/pixelhelper.rs`
|
||||
— signed `i32` offsets, clip against all four framebuffer edges, index 0 = transparent
|
||||
- [x] Input system: `GameAction`, `InputMap`, `InputState` in `client/src/input.rs` (over `pbio::Key`)
|
||||
- [x] Protocol spec: `notes/protocol.md` — Header, State, Action, Chunk, Entity, EntityQuery,
|
||||
EntityDetail, Ping/Pong; reliability model
|
||||
- [x] Shared crate: packet structs as `bytemuck::Pod` with compile-time size assertions,
|
||||
`player_action` / `packet_type` constants, `chunk_id` / `chunk_coords`
|
||||
- [x] Server binary: 24 Hz tick loop, 3×3-chunk test world (`build_world`), wall collision,
|
||||
per-client action queue, `ChunkPacket` dispatch on cache miss, stale-client eviction
|
||||
- [x] Client net: `NetClient` in `client/src/net.rs`, non-blocking UDP recv,
|
||||
`StatePacket` / `EntityPacket` / `ChunkPacket` / `PongPacket` dispatch, entity-checksum retransmit
|
||||
- [x] End-to-end loop: player moves on server, position reflected in `EntityPacket`, rendered on client
|
||||
- [x] Tile rendering: chunk-based world rendering from received `ChunkPacket` data (6-bit unpack)
|
||||
- [x] Tick-based movement: movement resolved in `entity_tick`, currently every 4th tick (`sim.rs`)
|
||||
- [x] Sprite-based entity rendering: entity `type_id` → tile in `entities.tga`, blitted with transparency
|
||||
- [x] Phase-staggered broadcast: per-client `send_phase`, flat outbound load across ticks
|
||||
- [x] Ping/Pong RTT measurement (`PingPacket` / `PongPacket`)
|
||||
- [x] Connection timeout: client emits `Disconnected` after 10 s without a `StatePacket`;
|
||||
server evicts clients unseen for 10 s
|
||||
|
||||
---
|
||||
|
||||
## Upcoming work
|
||||
|
||||
### 06 — Hybrid tick loop *(partially done)*
|
||||
|
||||
Design of record: **`notes/06-hybrid-tick-loop.md`** (24 Hz base tick + stride scheduling).
|
||||
This supersedes the original 20/10/1 Hz three-tier sketch.
|
||||
|
||||
- [x] 24 Hz base loop (`server/src/main.rs`)
|
||||
- [x] Phase-staggered broadcast at 12 Hz/client (`send_phase` in `server/src/net.rs`)
|
||||
- [x] Stride layout settled: `entity_tick` at `%4` (6 Hz, sets movement rate), broadcast at
|
||||
`%2` (12 Hz/client). The `06` note now documents this and why.
|
||||
- [ ] Action-point model (`energy` / `speed` per entity, act when `energy >= ACTION_COST`).
|
||||
Removed from `entity.rs` for now as premature — reintroduce when NPCs (08) actually need it.
|
||||
Then `entity_tick` can move to `%2` (12 Hz) and `speed` sets the effective movement rate.
|
||||
|
||||
---
|
||||
|
||||
### 07 — Tiled map loader
|
||||
|
||||
Replace the hardcoded `build_world` wall loop with real level data.
|
||||
|
||||
- Parse Tiled TMX (XML) or JSON export — only the subset actually used
|
||||
- Define the tile vocabulary: `TileKind` variants map 1:1 to Tiled tile IDs
|
||||
- Load a hand-authored starting area as the first real level
|
||||
- Procedural generation comes later; hand-authored first
|
||||
|
||||
---
|
||||
|
||||
### 08 — Basic NPC entity + AI budget
|
||||
|
||||
One dumb wandering enemy. Validates the simulation architecture before complexity accumulates.
|
||||
Depends on the action-point model from 06 being reintroduced.
|
||||
|
||||
- Add `EntityKind::Npc` with a `think() -> u32` method returning budget cost
|
||||
- Per sim-tick: distribute `think_budget = BASE / (clients + 1)` across entities ordered by player proximity
|
||||
- Complex entities consume more budget; simple ones less. Loop breaks at zero — natural load shedding.
|
||||
- No framework. No trait objects yet. A match on `EntityKind` is fine.
|
||||
|
||||
```rust
|
||||
let mut budget: u32 = BASE_BUDGET / (client_count + 1).max(1);
|
||||
for entity in entities_by_player_proximity() {
|
||||
if budget == 0 { break; }
|
||||
budget = budget.saturating_sub(entity.think(&mut world));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 09 — Client camera *(sprite rendering already done)*
|
||||
|
||||
Sprite rendering already landed (see Achieved). What remains is the camera:
|
||||
|
||||
- Smooth camera: lerp between last known and current server position; do not snap
|
||||
(currently `player_pos` snaps hard to the server position in `game.rs`)
|
||||
- Store previous + current position per entity, interpolate on render
|
||||
- Camera math is architectural — affects how entity state is stored. Do it before UI.
|
||||
|
||||
---
|
||||
|
||||
### Later — UI / HUD
|
||||
|
||||
- Bitmap font renderer: 8×8 glyph sheet TGA, `draw_text(frame, font, x, y, color, text)` — see `notes/04`
|
||||
- UI primitives in `client/src/ui.rs`: `fill_rect`, `draw_rect`, `draw_hbar` — see `notes/05`
|
||||
- HUD layout (320×240): HP bar + player name bottom 16px, message log right panel
|
||||
- A real clip-rect on the blit primitives belongs here — once the viewport and HUD panels are
|
||||
two distinct regions, the abstraction earns its keep. Not before.
|
||||
|
||||
---
|
||||
|
||||
### Later — Robustness + auth
|
||||
|
||||
- Auth token handshake: replace source-address identity (`auth_token` field exists but is unused)
|
||||
- Multi-datagram `EntityPacket`: server currently truncates at 66 entities (`net.rs` TODO)
|
||||
- Asset embedding: `include_bytes!` for single-binary distribution
|
||||
|
||||
---
|
||||
|
||||
### Later — World depth
|
||||
|
||||
Once the simulation architecture is stable and a real level exists:
|
||||
|
||||
- Doors, interactive objects (server-authoritative state)
|
||||
- Combat: melee range check, HP, death, respawn
|
||||
- Inventory: on-demand via `EntityQueryPacket` / `EntityDetailPacket`
|
||||
- Day/night and weather on the slow tick — affect visibility, spawns
|
||||
- Procedural dungeon generation (BSP or cellular automata feeding into the same tile format)
|
||||
|
||||
---
|
||||
</content>
|
||||
Reference in New Issue
Block a user