The sheet becomes a fixed grid (one row per entity, one column slot per animation state) described by a sidecar entities.toml that only maps names to rows; the loader reads present states and frame counts off the pixels, so assembling the sheet by hand needs no bookkeeping. tools/mktemplate.py renders the labelled template with low-alpha slot guides. The CGA atlas is kept for larger UI text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
Executable File
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. 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
- PNG loader:
Image::from_png(via thepngcrate, quantized to RGB565 on load, alpha mask),to_tileset→Vec<Tile>ingame/src/assets.rs. Framebuffer is 16-bit truecolor (pbiobranchtruecolor16) - Pixel helpers:
set_pixel,extract,blit,blit_tileingame/src/game/pixelhelper.rs— signedi32offsets, clip against all four framebuffer edges, per-tile opacity mask - Input system:
GameAction,InputMap,InputStateingame/src/input.rs(overpbio::Key) - World model:
Worldof 32×32Chunks (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_largeis a generated 256×256 stress map - Map loader: Tiled-CSV parser in
sim/src/map.rs(flip-flag masking, content-derived dimensions),load_worldslices it into chunks, invisible solid border at the map rim. Seenotes/07-map-loader.md - Tile rendering: chunk-based world rendering straight from
World(render_viewport) - Sprite-based entity rendering: entity
type_id→ sprite row inentities.png(via theentities.tomlmanifest, 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. - Hybrid tick loop: 24 Hz base tick, movement in
entity_tickevery 4th tick (6 Hz); the game loop drives the sim with a fixed-timestep accumulator. Seenotes/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 insim/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 (
camingame.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) andWorld::blocked(solid ∨ occupied). The sim checks the geometry rule againstsolidand resolves the target tile itself; the pathfinder plans withblocked. Intents execute in id order, so the player (id 1) wins a contested tile deterministically. - First NPC:
EntityKind { Player, Npc }onEntity;Sim::thinkruns before every movement window and schedules a random king move for each NPC one window in four (seeded xorshift inSim). Sprite = tile 1 inentities.png(a hen from the Mini-Medieval pack). One is spawned at (6, 6) inGame::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::attackdamage,hpsaturating). At 0 hp the entity is removed. The sim reports both through its event queue —sim::Event { Attacked, Died }, drained by the game withSim::take_eventsafter 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 sametype_id(species) is a refused move, not a fight. - NPC brains:
Brain { Idle, Wander, Hunt(Prey) }as a field onEntity— strategy is data, independent of species and swappable at runtime.Sim::thinkmatches on it.Hunttakes the first step of an A* route (the pathfinder moved intosim/for this) to the nearest prey withinSIGHT_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 offmap_testin about a minute;cargo test -p game population -- --ignored --nocapturewatches it headlessly. - Entity interpolation: per-id lerp table (
EntityLerpingame.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_tickat%4(6 Hz, sets movement rate) - Action-point model (
energy/speedper entity, act whenenergy >= ACTION_COST). Removed fromentity.rsfor now as premature — reintroduce when NPCs (08) actually need it. Thenentity_tickcan move to%2(12 Hz) andspeedsets 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::thinkscheduling intents through the same intent table- Bump-to-attack, death,
Eventqueue Brainenum,Huntvia A*- A* per hunter per window is fine for six foxes; the think budget below becomes
necessary once hunters number in the dozens (or
huntcaches 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::Huntcould switch target onAttacked. - Stats beyond
hp/attack(defense, speed) as plain fields; aStatssubstruct once they crowdEntity. 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) —
thinkwould need to see the previous window's events; today the game drains them right afterstep - Per sim-tick: distribute a fixed
think_budgetacross 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
EntityKindis 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 fromwds. The atlas isassets/fonts/micro-chat-8x9.png, repacked bytools/mkfont.pyfrom VEXED's Micro Chat sheet (5 px font, 8 px line height). Seenotes/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
Simvalue — 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)