Files
snow_trail/CLAUDE.md
2026-01-01 19:54:00 +01:00

24 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.

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
  • 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 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
  • 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 exr crate (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 lighting
  • shaders/terrain.wgsl - Terrain rendering with height displacement
  • 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 (WGSL) with diffuse+ambient lighting
  • terrain.rs - Terrain mesh generation and pipeline creation
  • postprocess.rs - Low-res framebuffer and blit shader for upscaling
  • mesh.rs - Vertex/Mesh structs, plane/cube mesh generation, glTF loading
  • heightmap.rs - EXR heightmap loading using exr crate
  • 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)
  • 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::Nearest sampler

wgpu Texture Formats

  • R32Float = single-channel 32-bit float, non-filterable
  • Use TextureSampleType::Float { filterable: false } in bind group layout
  • Use SamplerBindingType::NonFiltering for sampler binding
  • Attempting linear filtering on R32Float causes validation errors

Multiple Render Pipelines

  • Pipeline enum 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 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

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:

  1. Implement movement_system to apply InputComponent to physics
  2. Configure player state machine transitions
  3. Implement ground detection
  4. Add camera follow system
  5. Integrate snow deformation

The noclip camera mode serves as the primary navigation method for testing. Press 'N' to toggle noclip mode.