Files
forgotten_caves/notes/roadmap.md
T
irrlichtandClaude Opus 5 bb12f095d8 Add bitmap font, UI primitives and a HUD with message log
Ports the CP437 font renderer from wds to the framebuffer: proportional
advance from measured ink bounds, luminance-keyed mask, word wrap. The
right 80 px strip becomes a HUD with name, HP bar, population and a log
fed from sim events; Attacked/Died now carry the type ids the log needs,
since the victim is gone from the world by the time events are read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 17:31:17 +02:00

11 KiB
Executable File
Raw Blame History

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.

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

  • PNG loader: Image::from_png (via the png crate, quantized to RGB565 on load, alpha mask), to_tileset → Vec<Tile> in game/src/assets.rs. Framebuffer is 16-bit truecolor (pbio branch truecolor16)
  • 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
  • Input system: GameAction, InputMap, InputState in game/src/input.rs (over pbio::Key)
  • World model: World of 32×32 Chunks (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/map_large is a generated 256×256 stress map
  • 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
  • Tile rendering: chunk-based world rendering straight from World (render_viewport)
  • Sprite-based entity rendering: entity type_id → tile in entities.png, blitted with transparency
  • 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
  • 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
  • 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.
  • 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.
  • 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.
  • 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).
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

  • 24 Hz base loop driven from the game loop
  • 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.

  • EntityKind::Npc, Sim::think scheduling intents through the same intent table
  • Bump-to-attack, death, Event queue
  • 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.
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)

  • 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/cga-8x8.png (Oldschool PC Font Pack CGA 8×8). See notes/04-text_renderer.md
  • UI primitives in game/src/ui.rs: fill_rect, draw_rect, draw_hbar
  • 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)