Files
snow_trail/CLAUDE.md
2026-02-08 14:06:35 +01:00

21 KiB
Raw Blame History

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.

Content Creation: Blender 5.0 is used for terrain modeling and asset export (glTF meshes + EXR heightmaps).

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.toml configuration
  • Always run cargo fmt before committing to ensure consistent formatting
  • Current rustfmt settings: brace_style = "AlwaysNextLine", control_brace_style = "AlwaysNextLine"
  • NO inline paths - always add use statements at the top of files (e.g., use std::rc::Rc; instead of std::rc::Rc inline in code)
  • NO inline use statements in functions - all use statements 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, scale
  • MeshStorage - Mesh data + render pipeline
  • PhysicsStorage - Rapier3d rigidbody/collider handles
  • MovementStorage - Movement config + state
  • InputStorage - Gameplay input commands
  • PlayerTagStorage - Marker for player entities
  • StateMachineStorage - Behavior state machines
  • All storages owned by single World struct 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:

  • sdl3 crate: v0.16.2 (high-level bindings)
  • sdl3-sys crate: 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
  • WESL 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
  • 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 reads InputState
  • 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 game
  • N: Toggle noclip mode
  • Mouse motion: Camera look (yaw/pitch)

Rendering Pipeline

wgpu Rendering System:

  • Low-res framebuffer (160×120) renders to texture
  • Final blit pass upscales framebuffer to window using nearest-neighbor sampling
  • Depth buffer for 3D rendering with proper occlusion

Terrain Rendering:

  • glTF mesh exported from Blender 5.0 with baked height values in vertices
  • No runtime displacement in shader - vertices rendered directly
  • Separate terrain pipeline for terrain-specific rendering
  • Terrain mesh has heights pre-baked during export for optimal performance

Terrain Physics:

  • EXR heightmap files loaded via exr crate (single-channel R32Float format)
  • Heightmap loaded directly into rapier3d heightfield collider
  • No runtime sampling or computation - instant loading
  • Both glTF and EXR exported from same Blender terrain, guaranteed to match

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

WESL shaders are stored in the src/shaders/ directory:

  • src/shaders/standard.wesl - Standard mesh rendering with directional lighting
  • src/shaders/terrain.wesl - Terrain rendering with shadow mapping (no displacement)
  • src/shaders/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 order
  • entity.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 component
  • state_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 execution
  • shader.rs - Standard mesh shader (WESL) with diffuse+ambient lighting
  • terrain.rs - Terrain entity spawning, glTF loading, EXR heightmap → physics collider
  • postprocess.rs - Low-res framebuffer and blit shader for upscaling
  • mesh.rs - Vertex/Mesh structs, plane/cube mesh generation, glTF loading
  • draw.rs - DrawManager (legacy, kept for compatibility)

Game Logic:

  • player.rs - Player entity spawning function
  • camera.rs - 3D camera with rotation and follow behavior
  • movement.rs - Movement configuration and state structs
  • state.rs - Generic StateMachine implementation
  • physics.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 for physics colliders)
  • kurbo: Bezier curve evaluation for movement acceleration curves

Technical Notes

EXR Heightmap Loading (Physics Only)

When loading EXR files with the exr crate for physics colliders:

  • 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()
  • Convert to nalgebra DMatrix for rapier3d heightfield collider
  • No GPU texture creation - physics data only

Blender Export Workflow

Using Blender 5.0 for terrain creation and export:

  • Export terrain as glTF with baked height values in mesh vertices
  • Export same terrain as EXR heightmap (single-channel R32Float)
  • Both files represent the same terrain data, guaranteeing visual/physics sync
  • glTF used for rendering (vertices rendered directly, no shader displacement)
  • EXR used for physics (loaded into rapier3d heightfield collider)

Multiple Render Pipelines

  • Pipeline enum determines which pipeline to use per DrawCall
  • Different pipelines can have different shaders, bind group layouts, uniforms
  • Terrain pipeline: shadow-mapped rendering with line hatching shading
  • Standard pipeline: basic mesh rendering with diffuse lighting
  • 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 World and 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 Clone requirement on events (fire-and-forget)
  • FnMut handlers allow stateful callbacks
  • Global add_listener() and emit() 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