# Roadmap 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, RGB565 framebuffer — lives in the external `pbio` crate (git dependency, branch `truecolor16`), not in this repo. Assets: `assets/packs/` holds the source packs untouched (Mini Medieval family by VEXED, CC-BY — "documented" sheets: labels and state columns are pixel text in the PNGs, readable by eye, not by script); `assets/tilesets/` is what the game actually loads; `assets/fonts/` the font sources and repacked atlases (Micro Chat for the HUD, CGA 8×8 kept for menus/titles); `assets/maps/` the Tiled CSV maps. Entity sprites: `tilesets/entities.png` is assembled by hand from the packs into a fixed grid described by the sidecar `entities.toml` — one row per entity, one column slot per state (idle 2, sit 2, walk 4, action1 4, action2 2, hit 2, dead 2 frames max). The manifest only maps `name → row` and the slot layout; which states and how many frames a row has, the loader (`game/src/sprites.rs`) reads off the pixels (leading non-empty cells; empty slot → falls back to idle). `tools/mktemplate.py` renders the paste-in template `entities-template.png` (labels, header, slot guides at alpha 48 — invisible to the loader) and carries existing sprites over, so adding rows is: edit the manifest, rerun the tool, paste, save as `entities.png`. `tools/realign_sheet.py ` turns a documented Animals/Monsters sheet into `assets/work/-aligned.png`: blank spacer rows removed, only the light frames, already in the slot columns — one block paste per sheet (Monsters → rows 1–9, Animals → rows 10–83; the manifest order follows the sheets, including five foal/horse pairs). Game code refers to sprites by name (`sprites.id("hen")`); the id doubles as the entity's `type_id`. 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] PNG loader: `Image::from_png` (via the `png` crate, quantized to RGB565 on load, alpha mask), `to_tileset` → `Vec` in `game/src/assets.rs`. Framebuffer is 16-bit truecolor (`pbio` branch `truecolor16`) - [x] Pixel helpers: `set_pixel`, `extract`, `blit`, `blit_tile` in `game/src/game/pixelhelper.rs` — signed `i32` offsets, clip against all four framebuffer edges, per-tile opacity mask - [x] Input system: `GameAction`, `InputMap`, `InputState` in `game/src/input.rs` (over `pbio::Key`) - [x] World model: `World` of 32×32 `Chunk`s (plain `[u16; 1024]` tile ids, flags derived on lookup — the network-era 64-entry palette is gone), entities indexed per chunk, `entities_in_viewport`. Map path is the first CLI argument; `assets/maps/map_large` is a generated 256×256 stress map - [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. 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` → sprite row in `entities.png` (via the `entities.toml` manifest, see Assets above), idle frame blitted with transparency. Animation (walk while the lerp runs, hit/dead from events) is the next step — the sheet layout and loader already carry the frames. - [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. 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 (`sim/src/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 eases toward the player's *interpolated* render position (exponential approach, τ = 120 ms), 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 blocking: `World::solid` (collidable tile ∨ out of world) and `World::blocked` (solid ∨ occupied). The sim checks the geometry rule against `solid` and resolves the target tile itself; the pathfinder plans with `blocked`. Intents execute in id order, so the player (id 1) wins a contested tile deterministically. - [x] First NPC: `EntityKind { Player, Npc }` on `Entity`; `Sim::think` runs before every movement window and schedules a random king move for each NPC one window in four (seeded xorshift in `Sim`). Sprite = tile 1 in `entities.png` (a hen from the Mini-Medieval pack). One is spawned at (6, 6) in `Game::start`. - [x] Bump-to-attack and death: the geometry rule is checked against terrain only (`World::solid`); an entity on the target tile turns the step into an attack (`Entity::attack` damage, `hp` saturating). At 0 hp the entity is removed. The sim reports both through its event queue — `sim::Event { Attacked, Died }`, drained by the game with `Sim::take_events` after every window — which is the seam for sound, message log, camera shake, spawns. Player death quits for now. Click-to-move treats an occupied goal as a target (route ends in a bump) but walks around entities in the way; keyboard steps always bump. A bump into the same `type_id` (species) is a refused move, not a fight. - [x] NPC brains: `Brain { Idle, Wander, Hunt(Prey) }` as a field on `Entity` — strategy is data, independent of species and swappable at runtime. `Sim::think` matches on it. `Hunt` takes the first step of an A* route (the pathfinder moved into `sim/` for this) to the nearest prey within `SIGHT_RANGE` (8), replanned every window; out of sight or unreachable it wanders. A greedy step-toward-target was tried first and oscillated at any wall between hunter and prey. Six foxes (`Hunt(Kind(hen))`, 8 hp / 2 dmg) clear 150 wandering hens off `map_test` in about a minute; `cargo test -p game population -- --ignored --nocapture` watches it headlessly. - [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. --- ## Upcoming work ### 06 — Hybrid tick loop *(partially done)* Design of record: **`notes/06-hybrid-tick-loop.md`**. - [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. --- ### 07 — Tiled map loader *(done — see Achieved / `notes/07-map-loader.md`)* Remaining follow-ups when the need is concrete: - Collision vocabulary: seeded with test values (0, 146) in `sim::tile_collidable`. Decide on a proper file format for tile data (collision, opacity, …) instead of a hardcoded match. - Tile flipping: orientation is discarded on load; revisit with the sprite pass (09). - Multiple / object layers (spawns, triggers) — not yet parsed. The player currently spawns at (0, 0). --- ### 08 — Basic NPC entity + AI budget *(wanderer done — see Achieved)* The wandering NPC and entity blocking have landed; `Sim::think` is the hook where the budget model goes once there are enough NPCs to need it. The action-point model from 06 is still pending and only matters once NPC speeds should differ. - [x] `EntityKind::Npc`, `Sim::think` scheduling intents through the same intent table - [x] Bump-to-attack, death, `Event` queue - [x] `Brain` enum, `Hunt` via A* - A* per hunter per window is fine for six foxes; the think budget below becomes necessary once hunters number in the dozens (or `hunt` caches its route and replans only when the target moved). - Hunters don't defend themselves: a fox bitten by a hen does not retaliate unless the hen happens to be its nearest prey. `Brain::Hunt` could switch target on `Attacked`. - Stats beyond `hp`/`attack` (defense, speed) as plain fields; a `Stats` substruct once they crowd `Entity`. No ECS until entities actually differ in *which* components they carry — see the discussion of 2026-09-19. - NPC reactions (flee when hit, aggro on the player) — `think` would need to see the previous window's events; today the game drains them right after `step` - 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; for entity in entities_by_player_proximity() { if budget == 0 { break; } budget = budget.saturating_sub(entity.think(&mut world)); } ``` --- ### 09 — Client camera *(done — see Achieved)* Sprite rendering, camera follow and entity interpolation have all landed. --- ### UI / HUD *(first pass done)* - [x] Bitmap font: `game/src/font.rs`, CP437 atlas (32×8 cells, codepoint = index), proportional advance from measured ink bounds, luminance-keyed mask, `wrap`. Ported from `wds`. The atlas is `assets/fonts/micro-chat-8x9.png`, repacked by `tools/mkfont.py` from VEXED's Micro Chat sheet (5 px font, 8 px line height). See `notes/04-text_renderer.md` - [x] UI primitives in `game/src/ui.rs`: `fill_rect`, `draw_rect`, `draw_hbar` - [x] HUD in the 80 px strip right of the viewport (`Game::render_hud`): name, HP bar, population, and a message log fed from the sim events (only fights involving the player; newest at the bottom, wrapped to the panel) - A real clip-rect on the blit primitives — the HUD currently relies on being drawn *after* the viewport, which overwrites the right-edge bleed. Fine until a panel overlaps the world. - Player name is a constant; a character/stat screen comes with inventory. --- ### Later — 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 --- ### Later — World depth Once the simulation architecture is stable and a real level exists: - 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)