24 KiB
CLAUDE.md
This file provides guidance to Claude Code when working with code in this repository.
Project Overview
This is a pure Rust game project using SDL3 for windowing/input, wgpu for rendering, rapier3d for physics, and a low-res retro aesthetic with dithering. This is a migration from the Godot-based snow_trail project, implementing the same snow deformation system and character controller without engine dependencies.
Code Style
Code Documentation Guidelines:
- NO inline comments unless ABSOLUTELY necessary
- Code must be self-documenting through clear naming and structure
- Use doc comments (
///) only for public APIs and complex algorithms - Avoid obvious comments that restate what the code does
- Let the code speak for itself
Formatting:
- All code must follow the project's
rustfmt.tomlconfiguration - Always run
cargo fmtbefore committing to ensure consistent formatting - Current rustfmt settings: brace_style = "AlwaysNextLine", control_brace_style = "AlwaysNextLine"
- NO inline paths - always add
usestatements at the top of files (e.g.,use std::rc::Rc;instead ofstd::rc::Rcinline in code) - NO inline
usestatements in functions - allusestatements must be at the file level (module top), not inside function bodies or impl blocks
Architecture Decisions
ECS Architecture
The project uses a pure ECS (Entity Component System) architecture:
Entities:
- Just IDs (
EntityHandle = u64) - Managed by
EntityManager(spawn/despawn) - No data themselves - just containers for components
Components:
- Pure data structures stored in component storages
- Each storage is a
HashMap<EntityHandle, ComponentData> - No
Rc<RefCell<>>- clean ownership model - Components: Transform, Mesh, Physics, Movement, Input, PlayerTag, StateMachine
Systems:
- Functions that query entities with specific component combinations
- Run each frame in defined order
- Read from and write to component storages
- Examples:
player_input_system,state_machine_system,physics_sync_system,render_system
Component Storages (World-owned):
TransformStorage- Position, rotation, scaleMeshStorage- Mesh data + render pipelinePhysicsStorage- Rapier3d rigidbody/collider handlesMovementStorage- Movement config + stateInputStorage- Gameplay input commandsPlayerTagStorage- Marker for player entitiesStateMachineStorage- Behavior state machines- All storages owned by single
Worldstruct for clean ownership
Key Benefits:
- No
Rc<RefCell<>>needed - components are just data - Clear data flow through systems
- Easy to add/remove components at runtime
- Testable - systems are pure functions
- StateMachine integrates as a component for complex behaviors
- EventBus remains for irregular events and cross-system messaging
SDL3 vs SDL2
We are using SDL3 (latest stable bindings) rather than SDL2. SDL3 provides:
- Modern GPU API integration
- Better input handling
- Active development and future-proofing
As of December 2025, SDL3 Rust bindings are usable but still maturing:
sdl3crate: v0.16.2 (high-level bindings)sdl3-syscrate: v0.5.11 (low-level FFI)- Some features may be incomplete, but core functionality is stable
wgpu for Rendering
Using wgpu instead of OpenGL:
- Modern GPU API abstraction (Vulkan/Metal/DX12/OpenGL backends)
- Better cross-platform support
- WGSL shader language (WebGPU Shading Language)
- Type-safe API with explicit resource management
- Low-res framebuffer rendering with 3-bit RGB dithering (retro aesthetic)
Rendering Architecture:
- wgpu for 3D mesh rendering with custom shaders
- Low-resolution framebuffer (160×120) upscaled to window size
- Bayer 8×8 dithering for 3-bit RGB color (8 colors total)
- Multiple rendering pipelines: standard meshes and terrain
- Separate bind groups for different material types
Future: Debug UI
- Debug UI system not yet implemented
- Will be used for real-time parameter tweaking (replacing Godot's exported properties)
- Current debugging relies on println! and recompilation
Physics Integration
Using rapier3d for 3D physics:
- Character controller implemented manually (no built-in CharacterBody equivalent)
- Ground detection via raycasting with QueryPipeline
- Manual rigidbody velocity application
- State machine for movement states (Idle, Walking, Jumping, Falling)
Input Handling
Two-Layer Input Pipeline:
Layer 1: Raw Input (utility/input.rs - InputState):
- Global singleton for SDL event handling
- Tracks raw hardware state (W/A/S/D pressed, mouse delta, etc.)
- Handles SDL events via
handle_event()method - Manages global state (mouse capture, quit request, noclip mode)
- Lives in main event loop
Layer 2: Gameplay Commands (components/input.rs - InputComponent):
- Per-entity ECS component
- Stores processed gameplay commands (move_direction, jump_pressed)
- Filled by
player_input_system()which readsInputState - Used by movement systems to control entities
- Decouples input source from entity control
Input Flow:
SDL Events → InputState → player_input_system() → InputComponent → movement_system()
Current Input Layout:
W/A/S/D: Movement (converted to Vec3 direction in InputComponent)Space: Jump (sets jump_pressed in InputComponent)Shift: Speed boost (for noclip camera)I: Toggle mouse capture (lock/unlock cursor)Escape: Quit gameN: Toggle noclip mode- Mouse motion: Camera look (yaw/pitch)
Rendering Pipeline
wgpu Rendering System:
- Low-res framebuffer (160×120) renders to texture
- Bayer 8×8 dithering reduces colors to 3-bit RGB (8 colors)
- Final blit pass upscales framebuffer to window using nearest-neighbor sampling
- Depth buffer for 3D rendering with proper occlusion
Terrain Height Deformation:
- EXR heightmap files loaded via
exrcrate (single-channel R32Float format) - Height displacement applied in vertex shader
- Separate terrain pipeline with texture sampling in vertex stage
- TerrainUniforms includes height_scale parameter for tweaking displacement strength
- R32Float textures require non-filterable samplers (FilterMode::Nearest)
Lighting Model:
- Directional light (like Godot's DirectionalLight3D)
- Diffuse + ambient lighting (basic Phong model, no specular)
- Light direction is uniform across entire scene
- No attenuation or distance falloff
- Dithering applied after lighting calculations
Migration from Godot
This project ports the snow_trail Godot project (located at ~/shared/projects/snow_trail) to pure Rust:
What carries over:
- Snow deformation compute shader logic (GLSL can be reused with minor adjustments)
- Character controller state machine architecture
- Movement physics parameters
- Camera follow behavior
What changes:
- No
Base<Node3D>pattern → Pure ECS with EntityHandle + Components - No Godot scene tree → Entity-Component-System architecture
- No exported properties → Components with data (debug UI planned for future)
- rapier3d RigidBodyHandle in PhysicsComponent instead of Gd
- Manual ground detection instead of CharacterBody3D.is_on_floor()
- Component storages (TransformStorage, MeshStorage, etc.) instead of Godot nodes
- Systems (player_input_system, state_machine_system, etc.) instead of _process()
- No
Rc<RefCell<>>- components are just data in hashmaps - Event bus implemented from scratch (complementary to systems)
- State machine implemented from scratch (integrates as ECS component)
Build Commands
cargo build
cargo build --release
cargo check
cargo test
cargo run
cargo fmt
Shader Files
WGSL shaders are stored in the shaders/ directory:
shaders/standard.wgsl- Standard mesh rendering with directional lightingshaders/terrain.wgsl- Terrain rendering with height displacementshaders/blit.wgsl- Fullscreen blit for upscaling low-res framebuffer
Shaders are loaded at runtime via std::fs::read_to_string(), allowing hot-reloading by restarting the application.
Module Structure
Core:
main.rs- SDL3 event loop, game loop orchestration, system execution orderentity.rs- EntityManager for entity lifecycle (spawn/despawn/query)world.rs- World struct that owns all component storages and EntityManager
ECS Components (components/):
input.rs- InputComponent (gameplay commands)mesh.rs- MeshComponent (mesh + pipeline)movement.rs- MovementComponent (movement config/state)physics.rs- PhysicsComponent (rigidbody/collider handles)player_tag.rs- PlayerTag marker componentstate_machine.rs- (empty, StateMachine defined in state.rs)- Note: Component storages are defined in
world.rs, not in component files
ECS Systems (systems/):
input.rs- player_input_system (InputState → InputComponent)state_machine.rs- state_machine_system (updates all state machines)physics_sync.rs- physics_sync_system (physics → transforms)render.rs- render_system (queries entities, generates DrawCalls)
Rendering:
render.rs- wgpu renderer, pipelines, bind groups, DrawCall executionshader.rs- Standard mesh shader (WGSL) with diffuse+ambient lightingterrain.rs- Terrain mesh generation and pipeline creationpostprocess.rs- Low-res framebuffer and blit shader for upscalingmesh.rs- Vertex/Mesh structs, plane/cube mesh generation, glTF loadingheightmap.rs- EXR heightmap loading usingexrcratedraw.rs- DrawManager (legacy, kept for compatibility)
Game Logic:
player.rs- Player entity spawning functioncamera.rs- 3D camera with rotation and follow behaviormovement.rs- Movement configuration and state structsstate.rs- Generic StateMachine implementationphysics.rs- PhysicsManager singleton (rapier3d world)
Utilities:
utility/input.rs- InputState (raw SDL input handling)utility/time.rs- Time singleton (game time tracking)utility/transform.rs- Transform struct (position/rotation/scale data type)
Debug:
debug/noclip.rs- Noclip camera controller for development
Other:
event.rs- Type-safe event bus (complementary to ECS for irregular events)picking.rs- Ray casting for mouse picking (unused currently)
Dependencies Rationale
- sdl3: Windowing, input events, and platform integration
- wgpu: Modern GPU API abstraction for rendering (Vulkan/Metal/DX12 backends)
- pollster: Simple blocking executor for async wgpu initialization
- rapier3d: Fast physics engine with good Rust integration
- glam: Fast vector/matrix math library (vec3, mat4, quaternions)
- nalgebra: Linear algebra for rapier3d integration (Isometry3 conversions)
- bytemuck: Safe byte casting for GPU buffer uploads (Pod/Zeroable for vertex data)
- anyhow: Ergonomic error handling
- gltf: Loading 3D models in glTF format
- exr: Loading EXR heightmap files (single-channel float data)
- image: Image loading and processing (includes EXR support)
- half: Float16 support (dependency of exr)
- kurbo: Bezier curve evaluation for movement acceleration curves
Technical Notes
EXR Heightmap Loading
When loading EXR files with the exr crate:
- Must import traits:
use exr::prelude::{ReadChannels, ReadLayers}; - Use builder pattern:
.no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_file(path) - Extract float data:
channel.sample_data.values_as_f32().collect() - Create R32Float texture for height data
- R32Float is non-filterable, requires
FilterMode::Nearestsampler
wgpu Texture Formats
- R32Float = single-channel 32-bit float, non-filterable
- Use
TextureSampleType::Float { filterable: false }in bind group layout - Use
SamplerBindingType::NonFilteringfor sampler binding - Attempting linear filtering on R32Float causes validation errors
Multiple Render Pipelines
Pipelineenum determines which pipeline to use per DrawCall- Different pipelines can have different shaders, bind group layouts, uniforms
- Terrain pipeline: includes height texture binding in vertex stage
- Standard pipeline: basic mesh rendering without height displacement
- Each pipeline writes to its own uniform buffer before rendering
ECS Component Storages
Pattern:
All component storages are owned by the World struct:
pub struct World {
pub entities: EntityManager,
pub transforms: TransformStorage,
pub meshes: MeshStorage,
pub physics: PhysicsStorage,
pub movements: MovementStorage,
pub inputs: InputStorage,
pub player_tags: PlayerTagStorage,
pub state_machines: StateMachineStorage,
}
pub struct TransformStorage {
pub components: HashMap<EntityHandle, Transform>,
}
impl TransformStorage {
pub fn insert(&mut self, entity: EntityHandle, component: Transform) { }
pub fn get(&self, entity: EntityHandle) -> Option<&Transform> { }
pub fn with_mut<F, R>(&mut self, entity: EntityHandle, f: F) -> Option<R> { }
pub fn remove(&mut self, entity: EntityHandle) { }
pub fn all(&self) -> Vec<EntityHandle> { }
}
Key Features:
- No
Rc<RefCell<>>needed - clean ownership model - World owns all component data - explicit ownership
- Instance methods instead of static methods
- Systems receive
&mut World- clear data dependencies - Easy to test - can create multiple worlds
- Safe lookups return
Option
Example Usage:
// Create world and entity
let mut world = World::new();
let entity = world.spawn();
// Insert components via world
world.transforms.insert(entity, Transform::IDENTITY);
world.meshes.insert(entity, MeshComponent { ... });
// Query and update via world
world.transforms.with_mut(entity, |transform| {
transform.position += velocity * delta;
});
// Systems receive world
pub fn my_system(world: &mut World) {
for entity in world.player_tags.all() {
if let Some(input) = world.inputs.get(entity) {
// Process input...
}
}
}
// Cleanup
world.despawn(entity); // Removes from all storages
State Machine as ECS Component
StateMachine integrates into ECS as a component for complex entity behaviors:
State Machine Pattern:
- StateMachine owns all states via
HashMap<TypeId, Box<dyn State>> - TypeId-based state identification
- Transition conditions are simple closures (can capture entity ID)
- State callbacks receive
&mut Worldand can access any component - Updated by
state_machine_system()each frame using safe remove/insert pattern
Integration Example:
// Create state machine for entity
let mut sm = StateMachine::new(Box::new(IdleState { entity }));
sm.add_state(WalkingState { entity });
// Transitions can capture entity for checking
sm.add_transition::<IdleState, WalkingState>(move || {
// Note: transitions run before update, so they don't access world
false // Placeholder - implement proper transition logic
});
// Insert into world
world.state_machines.insert(entity, sm);
// States receive world when updated
impl State for IdleState {
fn on_state_update(&mut self, world: &mut World, delta: f32) {
// States can access any component via world
if let Some(input) = world.inputs.get(self.entity) {
// React to input...
}
}
}
State Machine System (Safe Pattern):
pub fn state_machine_system(world: &mut World, delta: f32) {
let entities: Vec<_> = world.state_machines.all();
for entity in entities {
// Temporarily remove state machine to avoid borrow conflicts
if let Some(mut state_machine) = world.state_machines.components.remove(&entity) {
state_machine.update(world, delta); // States can now safely access world
world.state_machines.components.insert(entity, state_machine);
}
}
}
Event System (event.rs)
Complementary to ECS:
- Events handle irregular, one-time occurrences
- Systems handle regular per-frame updates
- Events enable cross-system messaging without tight coupling
Event Bus Features:
- No
Clonerequirement on events (fire-and-forget) FnMuthandlers allow stateful callbacks- Global
add_listener()andemit()functions - Handlers can access ECS components directly via storages
ECS Integration:
#[derive(Debug)]
struct FootstepEvent { position: Vec3, force: f32 }
impl Event for FootstepEvent {}
// System emits event
pub fn foot_contact_system(world: &World) {
for player in world.player_tags.all() {
if is_on_ground(player) {
let pos = world.transforms.get(player).unwrap().position;
emit(&FootstepEvent { position: pos, force: 10.0 });
}
}
}
// Event handler (global listener, not part of World)
add_listener(|event: &FootstepEvent| {
snow_terrain::deform_at_position(event.position, event.force);
});
When to use:
- ✅ One-time events (collision, death, pickup)
- ✅ Cross-system communication (audio, VFX triggers)
- ✅ Spawning/despawning entities
- ❌ Regular updates (use systems instead)
ECS Systems
System Execution Order (main.rs game loop):
let mut world = World::new();
'running: loop {
// 1. SDL Events → InputState
for event in event_pump.poll_iter() {
input_state.handle_event(&event);
}
// 2. InputState → InputComponent (player_input_system)
player_input_system(&mut world, &input_state);
// 3. Update state machines (state_machine_system)
state_machine_system(&mut world, delta);
// 4. Simulate physics (PhysicsManager)
PhysicsManager::physics_step();
// 5. Sync physics → transforms (physics_sync_system)
physics_sync_system(&mut world);
// 6. Render (render_system)
let draw_calls = render_system(&world);
render::render(&camera, &draw_calls, time);
// 7. Cleanup
input_state.clear_just_pressed();
}
System Patterns:
Query Pattern:
pub fn my_system(world: &mut World) {
let entities = world.my_storage.all(); // All entities with this component
for entity in entities {
world.my_storage.with_mut(entity, |component| {
// Process component
});
}
}
Multi-Component Query:
pub fn movement_system(world: &mut World) {
for entity in world.player_tags.all() {
if let Some(input) = world.inputs.get(entity) {
if let Some(movement) = world.movements.get(entity) {
world.transforms.with_mut(entity, |transform| {
// Update position based on input + movement
});
}
}
}
}
Movement System (movement.rs)
Configuration and state for character movement physics:
Horizontal Movement:
HorizontalMovementConfig: Parameters for ground movement (acceleration, damping, speed limits)HorizontalMovementState: Runtime state (input direction, flooring status, surface normal)- Uses Bezier curves (kurbo::CubicBez) for smooth acceleration ramps
- Separate damping for walking vs idle states
Vertical Movement:
VerticalMovementConfig: Jump parameters (height, duration, air control)VerticalMovementState: Jump execution tracking (progress, peak detection, abort state)- Bezier curves for jump height progression over time
- Peak detection allows early jump termination with smooth falloff
Key Features:
- Physics parameters tuned to match Godot prototype
- Curve-based interpolation for responsive feel
- State tracking for ground detection and jump execution
- Configurable air control and momentum limits
- Integration with Time singleton for execution timing
Usage Pattern:
let config = HorizontalMovementConfig::new();
let mut state = HorizontalMovementState::new();
state.move_input = Vec3::new(input.x, 0.0, input.z);
state.forward_direction = camera.forward();
state.is_floored = ground_check.is_grounded;
// Apply movement physics using config + state
Time System (utility/time.rs)
Global game time tracking using OnceLock singleton:
Implementation:
static GAME_START: OnceLock<Instant> = OnceLock::new();
pub struct Time;
impl Time {
pub fn init() { GAME_START.get_or_init(Instant::now); }
pub fn get_time_elapsed() -> f32 { /* ... */ }
}
Key Features:
- Thread-safe singleton using std::sync::OnceLock
- Single initialization point (call Time::init() at startup)
- Returns elapsed time as f32 seconds
- Used for animation, jump timing, and time-based effects
- Zero-cost after initialization (static lookup)
Usage:
Time::init(); // In main() before game loop
let time = Time::get_time_elapsed(); // Anywhere in code
Current Implementation Status
Implemented Features
ECS Architecture:
- ✅ Full ECS conversion completed
- ✅ Entity system with EntityManager (spawn/despawn/query)
- ✅ Component storages (Transform, Mesh, Physics, Movement, Input, PlayerTag, StateMachine)
- ✅ Systems pipeline (input → state machine → physics → physics sync → render)
- ✅ No
Rc<RefCell<>>- clean component ownership - ✅ Event bus integrated as complementary to systems
Core Rendering:
- ✅ wgpu renderer with Vulkan backend
- ✅ Low-res framebuffer (160×120) with Bayer dithering
- ✅ Multiple render pipelines (standard mesh + terrain)
- ✅ Directional lighting with diffuse + ambient
- ✅ EXR heightmap loading and terrain displacement
- ✅ glTF mesh loading
- ✅ render_system (ECS-based DrawCall generation)
Input System:
- ✅ Two-layer input pipeline (InputState → InputComponent)
- ✅ player_input_system converts raw input to gameplay commands
- ✅ SDL event handling in InputState
- ✅ Per-entity InputComponent for controllable entities
Camera & Debug:
- ✅ 3D camera with rotation (yaw/pitch)
- ✅ Noclip mode for development (in debug/noclip.rs)
- ✅ Mouse look with relative mouse mode
- ✅ Toggle with 'I' key, 'N' for noclip mode
Physics:
- ✅ rapier3d integration with PhysicsManager singleton
- ✅ PhysicsComponent storage (rigidbody/collider handles)
- ✅ physics_sync_system (syncs physics → transforms)
- ✅ Physics step integrated into game loop
- ⚠️ Ground detection not yet implemented
- ⚠️ Movement physics not yet connected
State Machines:
- ✅ Generic StateMachine implementation
- ✅ StateMachineStorage (ECS component)
- ✅ state_machine_system updates all state machines
- ✅ Transitions can query ECS components
- ⚠️ Player state transitions not yet configured
Player:
- ✅ Player entity spawning function
- ✅ Components: Transform, Mesh, Physics, Movement, Input, PlayerTag
- ⚠️ Movement system not yet implemented
- ⚠️ State machine not yet attached to player
- ⚠️ Currently inactive (noclip camera used instead)
Movement Configuration:
- ✅ Horizontal movement config (Bezier acceleration curves)
- ✅ Vertical movement config (jump mechanics)
- ✅ MovementComponent storage
- ⚠️ Movement system not yet implemented
- ⚠️ Not yet integrated with physics
Not Yet Implemented
- ❌ Movement system (apply InputComponent → physics velocities)
- ❌ Ground detection and collision response
- ❌ Player state machine configuration
- ❌ Camera follow behavior (tracks player entity)
- ❌ Snow deformation compute shaders
- ❌ Debug UI system
Current Focus
ECS migration is complete! The architecture is now fully entity-component-system based with clean separation of data and logic. The next steps are:
- Implement movement_system to apply InputComponent to physics
- Configure player state machine transitions
- Implement ground detection
- Add camera follow system
- Integrate snow deformation
The noclip camera mode serves as the primary navigation method for testing. Press 'N' to toggle noclip mode.