Character panel:
- Width 44 -> 52, height 44 -> 62
- Text rows spaced 8 apart (was 6) — no more squished text
- HP/XP bars 38 wide (was 32)
- Stats with double space between pairs
- Inventory section at bottom with more room
HUD bar:
- Height 18 -> 28
- Rows at y_top+2, +12, +22 (was +1, +7, +13)
Inventory overlay:
- Slots 12x10 (was 8x6), gap 3 (was 2)
- Panel auto-sizes larger
- Click hitboxes in main.rs updated to match
Fixed depth_shown_in_hud test for new HUD height.
All 171 tests + 14 scenarios pass.
- Press Tab to open/close inventory overlay
- 4x2 grid of item slots (8 slots) centered on screen
- Each slot shows 2-char glyph + item name label
- Mouse hover highlights slot with golden border
- Hover shows action hint (Use/Equip/Equipped)
- Left-click slot: use/equip item at that index
- Right-click slot: drop item at that index
- Equipped items shown at bottom of panel
- Character panel hidden when inventory is open (saves space)
- Shooting disabled while inventory is open (prevents accidental fire)
- Mouse position tracked in UI coordinates for slot detection
All 171 tests + 14 scenarios pass.
- Item.display_glyph() returns [char; 2] — base symbol + type code:
Dagger /) Sword || Bow ){ LeatherArmor [L PlateArmor {P
Shield OS HealthPotion !H ManaPotion !M Food %F Scroll ?S
- Item.display_string() returns the 2-char combo as a string
- Character panel inventory now shows 2-char glyphs with dimmed second char
- HUD weapon/armor display uses 2-char glyphs instead of truncated names
- Items in world grid still use single display_char() (1 cell = 1 char)
All 171 tests + 14 scenarios pass.
Spectrum system (src/ai/spectrum.rs):
- 6 spectrums: materials, temperature, light, entities, density, velocity
- Each renders a different ASCII view of the same world state
- materials: material display chars + entity chars
- temperature: heat levels as .-=+oxX#
- light: brightness as .:+oO*
- entities: entity positions only, background dots
- density: material density as .:+oO#
- velocity: entity speed + CA activity as .:+oO,
Tape recording (src/ai/tape.rs):
- --mode tape CLI with --tape-interval, --tape-output, --tape-json
- Records all 6 spectrums + world state every N ticks
- Each frame: tick, depth, kills, score, camera, player HP/pos, entity count
- Outputs human-readable text and JSON formats
Pipe protocol additions:
- get_spectrum: single spectrum view by name
- get_all_spectrums: all 6 spectrums in one response
- Response.spectrums field added
Architecture priorities updated:
- Graphics mode is PRIMARY renderer
- ASCII mode is DEBUG backend
- Terminal mode is LEGACY
- AI uses multi-spectrum ASCII layers for observation
All 171 tests + 14 scenarios pass.
- Player template is now asymmetric:
- Weapon arm extends forward (right side, x=3..4)
- Hair flows backward (left side, x=-3..-4)
- Cape drapes to the left (x=-3..-4)
- Front hand colored as 'weapon' (bright steel)
- Goblin template asymmetric: weapon arm extends forward (x=4)
- Entity.flip_facing() mirrors all rest_offsets X and body positions
- Entity.facing_right field (default true)
- Game loop detects mouse crossing player center X and calls flip_facing()
- Verlet constraints adapt smoothly to mirrored positions
- 'weapon' label added to preview_ascii
All 171 tests + 14 scenarios pass.
Character detail:
- Player: 68 body parts (was 42) — eyes, hair, cape, hat, gloves
- Goblin: 56 body parts (was 39) — red eyes, pointed ears, teeth, skin tones
- New body labels: eye, hair, cape, tooth, ear
- Updated preview_ascii label map for new part types
Mouse aiming (Noita-style):
- Track mouse position via CursorMoved events
- Track left-click via MouseInput events
- Left-click shoots projectile toward mouse cursor direction
- Player faces left/right based on mouse X relative to player screen X
- Keyboard shoot (hjkl) still works as fallback
- Player.facing_right field added
- WindowInput: mouse_x, mouse_y, mouse_left, shoot_mouse fields
- on_mouse_move(), on_mouse_button() handlers
- clear_keys() also clears mouse state on focus loss
All 171 tests + 14 scenarios pass.
World generation:
- 4 biomes: grassland, grassland, dirt, stone (left to right)
- Multi-octave terrain noise (base + detail sine waves)
- Random caves carved into underground (8 circular caves)
- Trees with wood trunks and grass canopies (5 trees)
- Stone stalactite formations underground (6 pillars)
- Preserved all material types (water, lava, sand, acid, stone wall)
Goblin AI:
- Goblins now shoot arrows at player from 20-60 cell range
- Arrow fires every 80 ticks with directional velocity
- Uses new spawn_arrow() helper on ProjectileManager
All 171 tests + 14 scenarios pass.
CRITICAL:
- Fix use_item duplicating weapons/armor (not removed from inventory)
- Fix unwrap() panic in update_ragdoll_entity
- Fix descend proceeding without stairs when player entity missing
HIGH:
- Fix projectile hardcoded id==0 preventing enemy projectiles hitting player
- Fix previously equipped item lost on re-equip (returned to inventory)
- Fix i32::abs() overflow panic in background_color (use wrapping_abs)
MEDIUM:
- Fix slime AI ignoring vertical direction to player
- Fix combat damage applied to dead player
- Fix camera movement keys non-functional (added cam_offset_x/y)
- Fix Unicode emoji status icons (replaced with ASCII F/P/I/B)
- Fix Unicode UI chars (█░·─│┌┐└┘◆■ → ASCII #-./|++++*#)
- Add missing char_bitmap glyphs (+, =, >, <, *, %, #, |, @, _)
LOW:
- Fix draw_messages writing to negative y coordinates
- Fix WindowInput losing key state on focus loss (clear_keys on Focused(false))
- Fix compute_lighting using full grid scan instead of gather_sources_in_range
All 171 tests + 14 scenarios pass.
New entity: Slime (EntityKind::Slime)
- 19 parts: blobby body (3x5) + 2 glowing eyes
- Green translucent colors, brighter center
- HP: 25, spawns every 45 ticks (max 2 alive)
Slime AI (update_slime_ai):
- Jumps toward player every 60 ticks when within 40 cells
- Jump power scales with proximity (closer = stronger)
- Pauses horizontally between jumps (50/50 hop-stop cycle)
- Uses set_horizontal_vel + set_vertical_vel (vector movement)
Combat system (update_combat):
- AABB overlap check between player and all alive enemies
- Goblin: 8 damage per 20 ticks on contact
- Slime: 5 damage per 20 ticks on contact
- Knockback: player pushed away from enemy on hit
- Player.take_damage() called, death possible from enemies
World gen: try_spawn_slime() spawns at surface, 18 cells from player
Renderers: slime rendered as 's' in ascii/terminal, green blob in graphics
6 new tests (122 total, 0 failures)
Entities are now ~3x larger and more detailed:
Player (47 parts, was 15):
- Hat: 3 top + 2 brim (dark purple)
- Head: 3x2 face (lavender skin)
- Belt: golden accent across torso
- Shirt: 3x2 torso + arms out to 3 cells each side
- Hands: 4 cells (2 per arm, pale lavender)
- Pants: 3 wide hips + 5 wide legs + split
- Boots: 3 cells + 2 foot tips (dark purple)
- half_w=4, half_h=6 (was 2.5x3.5)
Goblin (41 parts):
- Pointed ears (2 cells)
- Ragged shirt (green, darker shade)
- Bare feet (green skin, no boots)
- Loincloth (dark green)
Auto-constraints: all parts connected to all others (n^2)
- Simplified from manual constraint lists
- Works for any template shape
- Ragdoll will look natural — all parts hold together
macro_rules! p! for compact part definitions
117 tests, 0 failures
BodyTemplate: JSON-serializable entity body definition.
- parts: Vec<BodyPart> with x, y, color, label
- constraints: Vec<(usize, usize)> connecting parts
- half_w/half_h/radius: collider and physics params
Built-in templates:
- humanoid_player: purple wizard (15 parts)
- humanoid_goblin: green (15 parts)
- boulder: 4x4 rock (16 parts)
API for AI agents:
- BodyTemplate::to_json() / from_json() — serialize/deserialize
- BodyTemplate::preview_ascii() — text preview of silhouette
- BodyTemplate::apply_to(&mut entity, cx, cy) — build entity from template
- template_for_kind(EntityKind) — get template by entity type
- Custom templates: create any shape (snake, dragon, boss) from code or JSON
Entity::build_humanoid() now delegates to template_for_kind().apply_to()
— single source of truth for body layout.
7 new tests for template system (116 total, 0 failures)
Redesigned entity from solid 5x5 block to humanoid silhouette:
Shape (23 bodies, was 27):
H H H (head, 3 wide)
H H H
A T T T A (shoulders + arms, 5 wide)
A T T T A
T T T (torso, 3 wide)
L R (legs, split with gap)
L R
Per-part colors (Noita-inspired):
- Player: head=bright yellow, torso=golden, arms=amber, legs=dark gold
- Goblin: head=bright green, torso=green, arms=olive, legs=dark green
- Corpse: desaturated browns
SubBody now has color: [u8; 4] field, set by build_humanoid.
All renderers (terminal, ascii, graphics) use b.color directly.
Fire effect: flickering orange overlay on burning entities.
half_w adjusted from 3.5 to 3.0, half_h from 2.5 to 3.5
(taller shape, narrower than old block).
109 tests, 0 failures
Cells reduced from 16x16 to 10x10 pixels. Same window size now
shows more of the world — finer detail, more cells visible.
Adaptive viewport recalculates grid_w/grid_h from new cell size.
109 tests, 0 failures
Each cell now stores its own fg/bg color inline, instead of looking
up from MaterialRegistry every frame.
Cell struct:
- fg: [u8; 3] — foreground color (for rendering)
- bg: [u8; 3] — background color (for rendering)
- temp: f32 — temperature (already there)
- material: MaterialId — for physics property lookup
- variant: u8 — visual/behavioral variant
- updated_this_tick: bool — CA flag
Colors are copied from MaterialRegistry at Cell::new() time.
This allows per-cell color variation in the future (water depth
shading, lava gradient, damaged materials, etc.) without registry
changes.
MaterialRegistry still stores physics properties (density, solid,
liquid, flammable, etc.) — looked up only during CA/physics steps,
not during rendering.
Renderers (terminal, ascii, graphics) updated to use cell.fg/cell.bg
directly — no more registry lookup in render path.
Cell size: ~16 bytes (was ~12). 250x250 grid = 1MB (was 750KB).
126 tests, 0 failures
Wayland surface always reports current_extent as 0xFFFFFFFF (undefined),
meaning the swapchain size is determined by the application, not the
surface. Previous code returned early when this happened, so resize
never worked — cells stretched to fill the window.
Fix: when current_extent is 0xFFFFFFFF, use winit's window.inner_size()
to get the actual pixel dimensions. This works on both X11 and Wayland.
Both renderers (ascii + graphics) updated.
109 tests, 0 failures
Both renderers (ascii + graphics) now support dynamic window resize:
- check_resize() called at start of every render() frame
- Compares surface capabilities with current swapchain extent
- If changed: recreates swapchain, image views, framebuffers, command buffers
- Recalculates grid_w/grid_h from new extent / cell_size (16x16)
- Reallocates instance buffer if grid cell count changed
- Old swapchain properly destroyed after new one created
- Handles window minimize (skip if extent = 0)
- physical_device field added back to both renderers (needed for surface caps query)
main.rs: vw/vh queried every frame from renderer.grid_w()/grid_h()
instead of cached once at startup. Camera center uses dynamic dimensions.
Result: resize window → more/fewer cells visible, cells stay 16x16 pixels.
109 tests, 0 failures
Square cells:
- CHAR_W and CHAR_H both 16 (was 8x16, non-square)
- Window size updated to 160*16 x 50*16 = 2560x800
- Applies to both ascii and graphics renderers
Slope stepping collision:
- Before resolving X, check if new position overlaps solid
- If overlap, try stepping up 1 cell — if clear, snap up (walk up slope)
- If can't step up, resolve X normally (wall block)
- aabb_overlaps_solid() helper for fast overlap check
- Player can now walk up 1-cell-high steps and slopes
109 tests, 0 failures
Remove unused fields from VulkanRenderer: pixel_w, pixel_h,
physical_device, queue_family, swapchain_format. Mark unused
device param in create_swapchain as _device.
Only winit deprecation warnings remain (create_window, run).
Three render modes:
- --mode terminal: pure ANSI ASCII in terminal
- --mode ascii: Vulkan window with ASCII characters (glyph atlas)
- --mode graphics: Vulkan window with colored cells (no glyphs, each
material = unique base color, no lighting yet)
Graphics renderer:
- Same Vulkan pipeline as ASCII but without glyph atlas
- Simpler shaders: graphics.vert/frag just output instance color
- No descriptor set, no texture sampling
- Each cell = colored quad, material color fills entire cell
- Entities rendered as colored shapes (player=yellow, goblin=green)
Default mode changed to --mode ascii
109 tests, 0 failures
Use ash_window::enumerate_required_extensions() instead of hardcoded
VK_KHR_xlib_surface / VK_KHR_wayland_surface. This automatically
selects the correct surface extension per platform:
- Linux X11: VK_KHR_xlib_surface
- Linux Wayland: VK_KHR_wayland_surface
- Windows: VK_KHR_win32_surface
- macOS: VK_EXT_metal_surface (via MoltenVK)
No platform-specific code in the renderer — fully cross-platform.
109 tests, 0 failures
- Fix: Y coordinate was inverted (1.0 - 2.0*y → 2.0*y - 1.0)
Vulkan clip space Y is already down-up, no need to flip
- Fix: dynamic viewport/scissor with viewport_count(1) + scissor_count(1)
Required by validation layer even with dynamic state
- Clean: removed debug eprintlns, restored default clear color
- Shaders recompiled to SPIR-V
Replaced minifb with winit + softbuffer:
Input (layout-agnostic):
- winit uses PhysicalKey<KeyCode> which maps to physical key positions
- KeyCode::KeyA = physical A key, regardless of keyboard layout
- Works on Russian, Arabic, any layout — no key mapping needed
- HashSet<KeyCode> tracks pressed/released state
- Proper Press/Release events from OS, no timeout hacks
FPS improvements:
- Glyph atlas pre-built at startup (alpha bitmap, zero per-frame alloc)
- render_to_buffer: skip empty cells entirely (no draw call)
- blend_fast: bitshift instead of division for alpha blending
- Buffer fill via .fill() instead of nested loop
- copy_from_slice for pixel transfer to softbuffer (memcpy speed)
- ControlFlow::Poll for maximum frame rate
109 tests, 0 failures
Input fix: get_keys_pressed(No) only fires on initial press, not held.
Switched to get_keys() which returns all currently-down keys every frame.
Jump still edge-triggered via jump_was_down flag.
Performance fix: pre-build glyph atlas at startup (all ASCII chars
rasterized once into 128x256 alpha bitmap). No per-frame cloning.
draw_cell reads atlas alpha + blends fg/bg inline. Zero allocations
in render loop.
109 tests, 0 warnings
New --mode window (now default):
- minifb creates a real OS window (no terminal needed)
- fontdue rasterizes DejaVu Sans Mono glyphs to pixel buffer
- ASCII characters rendered as colored pixels — same aesthetic
- Keyboard polled via get_pressed_keys() every frame:
- Instant response, no terminal delay
- Proper multi-key support (W+A strafing works)
- No Release event hacks needed
- No key repeat timeout hacks
- Instant stop when key released
Controls: WASD/arrows, 1-9/0 paint, X erase, HJKL camera, Q/Esc quit
--mode terminal still available as fallback
109 tests, 0 warnings, 0 failures
Root cause of both issues: terminals don't send Release events by
default. When pressing W while holding A, terminal stops repeating A
(but doesn't send Release), so A times out and strafing breaks.
On key release, 400ms timeout means 400ms of extra movement.
Fix: PushKeyboardEnhancementFlags(REPORT_EVENT_TYPES) in terminal init.
This asks the terminal to send proper Press/Release/Repeat events for
ALL keys, not just special ones.
With Release events:
- Key release is instant (no 400ms timeout delay)
- Pressing W doesn't cancel A's held state (terminal sends Release
only when A is actually released)
- A/D strafing works while W is held
Fallback: if terminal doesn't support enhancement flags (old xterm),
got_release flag stays false and 150ms timeout is used. Once any
Release event is received, all keys switch to Release-based mode
(infinite timeout, rely on actual Release events).
109 tests, 0 warnings, 0 failures
Root cause: terminals wait 300-500ms after initial Press before
sending the first Repeat event. With 60ms timeout, the key was
released during this gap, causing step-pause-accelerate pattern.
Fix: two timeout phases:
- After Press (no Repeat seen yet): 400ms timeout
Covers terminal's initial repeat delay, key stays held
- After first Repeat: 80ms timeout
Repeats arrive every ~30ms, 80ms is safe margin
Quick release detection once repeating starts
Result: smooth continuous movement from first keypress, no gap
Problem: event::poll(0ms) once per frame missed events between frames,
causing laggy/missed input. Terminal key repeat timing was unpredictable.
Fix: dedicated input thread that blocks on event::read() and pushes
all events to an mpsc channel. Game loop drains channel with try_iter()
each frame — gets every event without blocking, zero missed inputs.
- InputHandler::start() spawns background thread
- InputHandler::stop() cleans up on game exit
- try_iter() collects all accumulated events per frame
- Removed 16ms sleep (no longer needed, input doesn't block)
- Hold timeout reduced to 60ms (events arrive more reliably now)
- 109 tests, 0 warnings, 0 failures
Changed from physics-based (accumulate + damping) to vector-based:
- Horizontal velocity is SET directly, not accumulated
- Press A → cvx = -0.5 (instant, no ramp-up)
- Release → cvx = 0 (instant stop, no sliding)
- No horizontal damping = precise control
- Air strafing: same horizontal speed in air as on ground
- Can change direction mid-jump freely
- Vertical: gravity + minimal damping (0.99) for natural fall
- Jump sets cvy directly, gravity pulls back
- move_speed 0.5 (was 0.3), max_vel 2.0 (was 1.0)
- Player: move_dir tracks current direction, stop_horizontal() clears velocity
- handle_input: calls stop_horizontal when no movement key held
109 tests, 0 warnings, 0 failures
Non-destructive overlay architecture:
- UiLayer: sparse map of screen positions → (char, fg, bg)
- Composites on top of world render, never writes to grid/entities
- Health bars, HUD, message log, inventory overlay, minimap, menus
- Terminal: HashMap overlay after world draw
- Vulkan: separate render pass on top of world
- Pipe protocol exports world state only (not UI)
- Key principle: UI reads state, renders visuals, never mutates game
- Added: ui/ module in file structure, 0.25 milestone, locked decision
Terminals don't send Release events for most keys, so held keys
stayed active forever. Two problems:
1. W = infinite jump: Jump was in held_actions, fired every tick
while held. check_on_ground returned true immediately after
landing, causing auto-bounce.
Fix: Jump fires only on initial Press event (jump_pressed flag),
not on Repeat or held state.
2. A ignores D: When both A and D were held, both applied velocity
and cancelled out. But terminal never sends Release for A, so
D couldn't take over.
Fix: Left/Right are mutually exclusive — most recently pressed
key wins (compares last_seen timestamps).
3. Held key timeout: Keys expire after 80ms without a Repeat event,
simulating Release for terminals that don't send it.
109 tests, 0 warnings, 0 failures
Problem: terminal key repeat sends one event, then pauses ~500ms,
then floods repeats. This caused stop-then-accelerate movement.
Fix: track held keys (Press/Release events), apply movement every
tick based on held state, not per-event. Jump still fires once per
press. Paint and quit are one-shot actions.
- InputHandler: held: Vec<HeldKey> tracks Left/Right/Jump/Camera
- update(): drains all pending events, updates held state
- held_actions(): returns active movement actions per tick
- handle_input: applies held actions every frame
- Camera pan reduced to 2/tick (was 5) for smoothness
- 109 tests, 0 warnings, 0 failures
- Renderer init/render/shutdown errors now printed instead of silently
swallowed with let _ =
- Auto-fixed all unused imports and variables (cargo fix)
- Zero compiler warnings