Journey Engine
A custom 2D game engine built with Rust and wGPU, designed for tight, expressive platformers. Powers Journey.
What's Here
| Section | What you'll find |
|---|---|
| Engine API | Public API reference with usage examples: GameApp, Context, input, physics, sprites, audio, animation |
| Technical Documentation | Architecture internals, game loop model, rendering pipeline, cross-platform strategy, dependency map |
| Procedural Audio | Guides and examples for using the custom Resonance (no_std DSP) and Cadence (Sequencer) audio stack |
Quick Links
- Play → journey.ujjwalvivek.com
- Engine Crate → crates.io/crates/journey-engine
- Audio Crate → crates.io/crates/journey-sound
- Synth Crate → crates.io/crates/journey-sequencer
- Source → github.com/ujjwalvivek/journey
At a Glance
[dependencies]
journey-engine = "1.2.0"
journey-sound = "1.0.0"
journey-sequencer = "1.0.0"
#![allow(unused)] fn main() { use engine::{Context, GameAction, GameApp}; struct MyGame; impl GameApp for MyGame { type Action = MyAction; fn init(ctx: &mut Context<MyAction>) -> Self { MyGame } fn update(&mut self, ctx: &mut Context<MyAction>) {} fn fixed_update(&mut self, ctx: &mut Context<MyAction>) {} fn render(&mut self, ctx: &mut Context<MyAction>) {} } }
See Engine API → Quick Start for the full minimal example.
Journey Engine API Reference
Public API documentation for the
journey-enginecrate with usage examples fromgame.Version: 1.1.2 · Crate type:
rlib
Table of Contents
- Quick Start
- GameApp Trait
- Context
- Input
- Physics
- Sprite and Rendering
- Texture Management
- Camera and Screen Shake
- Audio
- Animation
- Time
- Math Utilities
- Atmosphere and Scene
- Re-exports
Quick Start
Minimal Game
use engine::{Context, GameAction, GameApp, Vec2}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Action { MoveLeft, MoveRight, Jump } impl GameAction for Action { fn count() -> usize { 3 } fn index(&self) -> usize { *self as usize } fn from_index(i: usize) -> Option<Self> { match i { 0 => Some(Self::MoveLeft), 1 => Some(Self::MoveRight), 2 => Some(Self::Jump), _ => None } } fn move_negative_x() -> Option<Self> { Some(Self::MoveLeft) } fn move_positive_x() -> Option<Self> { Some(Self::MoveRight) } } struct MyGame; impl GameApp for MyGame { type Action = Action; fn init(ctx: &mut Context<Action>) -> Self { MyGame } fn update(&mut self, ctx: &mut Context<Action>) { //? Variable-rate update logic } fn render(&mut self, ctx: &mut Context<Action>) { ctx.draw_rect(Vec2::new(100.0, 100.0), Vec2::new(32.0, 32.0), [1.0, 0.0, 0.0, 1.0]); } } fn main() { engine::run::<MyGame>(); }
WASM Entry Point
#![allow(unused)] fn main() { use wasm_bindgen::prelude::*; #[wasm_bindgen(start)] pub fn wasm_main() { engine::run_wasm::<MyGame>(); } }
GameApp Trait
The core contract between engine and game. Implement this trait to define your game's lifecycle.
Module: engine (root)
#![allow(unused)] fn main() { pub trait GameApp: 'static { type Action: GameAction; fn init(ctx: &mut Context<Self::Action>) -> Self; fn fixed_update(&mut self, ctx: &mut Context<Self::Action>, fixed_time: &FixedTime) {} fn update(&mut self, ctx: &mut Context<Self::Action>); fn render(&mut self, ctx: &mut Context<Self::Action>); fn ui(&mut self, egui_ctx: &egui::Context, ctx: &mut Context<Self::Action>, scene_params: &mut SceneParams) {} //? Optional overrides with defaults: fn window_title() -> &'static str { "Journey Engine" } fn window_icon() -> Option<&'static [u8]> { None } fn wasm_ready_event() -> Option<&'static str> { None } fn internal_resolution() -> (u32, u32) { (640, 360) } } }
How game Uses It
#![allow(unused)] fn main() { impl GameApp for JourneyGame { type Action = JourneyAction; fn window_title() -> &'static str { "Journey" } fn window_icon() -> Option<&'static [u8]> { Some(include_bytes!("../../web/public/favicon.png")) } fn wasm_ready_event() -> Option<&'static str> { Some("journey:first-frame") } fn internal_resolution() -> (u32, u32) { (640, 360) } fn init(ctx: &mut Context<JourneyAction>) -> Self { //? Load textures during init and return a 1-based texture ID let tex_player = ctx.load_texture( include_bytes!("../assets/player/player.png"), "Player Spritesheet", ); //? Set up input bindings input::setup_default_bindings(&mut ctx.input); let level = Level::new(ctx.screen_width, ctx.screen_height); let player = Player::new(level.player_spawn, anim_state); Self { player, level, /* ... */ } } fn fixed_update(&mut self, ctx: &mut Context<JourneyAction>, fixed_time: &FixedTime) { //? All physics and combat run here at a fixed rate (default 60Hz) self.player.fixed_update(ctx.delta_time, fixed_time.tick, fixed_time.tick_rate(), /* ... */); } fn update(&mut self, ctx: &mut Context<JourneyAction>) { //? Camera smoothing with interpolation alpha let alpha = ctx.interpolation_alpha; let cam_x = lerp(self.prev_camera_x, self.camera_x, alpha); } fn render(&mut self, ctx: &mut Context<JourneyAction>) { //? Draw player sprite from sheet ctx.draw_sprite_from_sheet( position, size, color, source_rect, flip_x, texture_id, ); } } }
Context
The single mutable handle games use to interact with the engine. Passed to every GameApp method.
Module: engine::context
Public Fields
| Field | Type | Description |
|---|---|---|
input | InputState<A> | Current keyboard, mouse, and gamepad state (generic over your action enum) |
delta_time | f32 | Frame delta (variable in update, fixed in fixed_update) |
screen_width | f32 | Current viewport width in pixels |
screen_height | f32 | Current viewport height in pixels |
camera_offset_x | f32 | Horizontal camera pan offset |
camera_offset_y | f32 | Vertical camera pan offset |
fps | f32 | Current frames per second |
frame_time_ms | f32 | Last frame time in milliseconds |
fixed_tick_rate | u32 | Fixed update rate in Hz (default: 60) |
target_fps | u32 | Native display frame cap; WASM follows browser RAF/vsync |
interpolation_alpha | f32 | 0.0–1.0 fraction for render-time smoothing |
freeze_frames | u16 | Remaining freeze frames (hitstop) |
pending_shakes | Vec<(f32, f32)> | Queued screen shakes: (intensity, duration) |
request_exit | bool | Set to true to close the window |
fullscreen_enabled | bool | Whether fullscreen is currently enabled |
hdr_enabled | bool | Whether HDR output is active |
audio | AudioManager | Direct access to the audio system |
pending_ui_audio | Vec<UiAudioEvent> | UI audio events queued this frame |
bloom | BloomSettings | Persistent post-process bloom settings |
Methods
Texture Loading
#![allow(unused)] fn main() { pub fn load_texture(&mut self, bytes: &'static [u8], label: &str) -> usize }
Queue a texture for loading during init(). Returns a 1-based texture ID (0 is the built-in white pixel). The engine processes these after init completes.
#![allow(unused)] fn main() { //? In GameApp::init() let tex_player = ctx.load_texture( include_bytes!("../assets/player/player.png"), "Player Spritesheet", ); //? tex_player == 1 (first loaded texture) }
Drawing
#![allow(unused)] fn main() { //? Draw a colored rectangle pub fn draw_rect(&mut self, position: Vec2, size: Vec2, color: [f32; 4]) pub fn draw_rect_layer(&mut self, layer: RenderLayer, position: Vec2, size: Vec2, color: [f32; 4]) pub fn draw_rect_additive(&mut self, position: Vec2, size: Vec2, color: [f32; 4]) //? Draw a sprite with optional horizontal flip pub fn draw_sprite(&mut self, position: Vec2, size: Vec2, color: [f32; 4], flip_x: bool) //? Draw a region from a sprite sheet pub fn draw_sprite_from_sheet( &mut self, position: Vec2, //? Top-left corner in screen space size: Vec2, //? Width and height in pixels color: [f32; 4], //? RGBA tint (0.0–1.0 each) source_rect: Rect, //? Sprite sheet region in pixel coordinates flip_x: bool, //? Horizontal mirror texture_id: usize, //? 1+ for loaded textures, 0 for white pixel ) //? Same as above with additive blending (for effects/glow) pub fn draw_sprite_from_sheet_additive( &mut self, position: Vec2, size: Vec2, color: [f32; 4], source_rect: Rect, flip_x: bool, texture_id: usize, ) }
Usage from game:
#![allow(unused)] fn main() { //? Draw a solid red rectangle (health bar) ctx.draw_rect(Vec2::new(10.0, 10.0), Vec2::new(100.0, 8.0), [1.0, 0.0, 0.0, 1.0]); //? Draw the player from a spritesheet let frame_rect = Rect::new(col as f32 * 100.0, row as f32 * 100.0, 100.0, 100.0); ctx.draw_sprite_from_sheet( player_pos, Vec2::new(50.0, 50.0), [1.0, 1.0, 1.0, 1.0], //? White tint = original colors frame_rect, !player.facing_right(), 1, //? texture ID from load_texture ); //? Additive glow effect on a hit flash ctx.draw_sprite_from_sheet_additive( position, size, [1.0, 0.8, 0.2, 0.6], source_rect, flip_x, texture_id, ); //? Additive colored rectangle on the effects layer ctx.draw_rect_additive(position, size, [0.2, 0.9, 1.0, 0.5]); //? Debug geometry draws after world/effects sprites ctx.draw_rect_layer(RenderLayer::Debug, position, size, [0.0, 1.0, 0.0, 0.8]); }
Effects
#![allow(unused)] fn main() { //? Freeze physics and FSM for N render frames (hitstop) pub fn trigger_freeze(&mut self, frames: u16) //? Queue a screen shake with intensity and duration in seconds pub fn trigger_shake(&mut self, intensity: f32, duration: f32) //? Temporarily override bloom for this frame only pub fn override_bloom(&mut self, settings: BloomSettings) }
Usage from game:
#![allow(unused)] fn main() { //? Parry hitstop: 3-frame freeze + screen shake ctx.trigger_freeze(3); ctx.trigger_shake(4.0, 0.15); }
Bloom
#![allow(unused)] fn main() { pub struct BloomSettings { pub enabled: bool, pub threshold: f32, pub intensity: f32, pub radius: f32, } }
Bloom is a post-process primitive. It extracts bright pixels from the finished internal-resolution scene, samples a compact neighborhood, and composites the glow during the final blit. Use additive draw calls for neon/effect sources, then enable bloom globally through ctx.bloom or temporarily with ctx.override_bloom(...).
#![allow(unused)] fn main() { ctx.bloom.enabled = true; ctx.bloom.threshold = 0.65; ctx.bloom.intensity = 0.35; ctx.bloom.radius = 2.0; ctx.override_bloom(BloomSettings { enabled: true, threshold: 0.58, intensity: 0.28, radius: 2.0, }); }
Audio
The engine only manages UI-level audio events (UiAudioEvent). Game-specific audio events should be defined and queued by the game crate. The runtime automatically deduplicates pending_ui_audio each frame.
#![allow(unused)] fn main() { //? UI audio is handled automatically via the AudioResponse trait on egui widgets. //? Game-specific audio (jump, dash, etc.) is managed by the game crate's own event queue. }
Utility
#![allow(unused)] fn main() { pub fn screen_center(&self) -> Vec2 pub fn set_fullscreen_enabled(&mut self, enabled: bool) pub fn set_hdr_enabled(&mut self, enabled: bool) }
Input
Generic, action-based input system that decouples hardware keys from game intent. Each game defines its own action enum by implementing the GameAction trait.
Module: engine::input
GameAction Trait
The engine's input system is generic over this trait. There are no hardcoded actions, each game defines its own.
#![allow(unused)] fn main() { pub trait GameAction: Copy + Eq + Debug + 'static { fn count() -> usize; fn index(&self) -> usize; fn from_index(index: usize) -> Option<Self>; //? Optional: wire these up for get_move_x() / get_move_y() axis helpers fn move_negative_x() -> Option<Self> { None } fn move_positive_x() -> Option<Self> { None } fn move_negative_y() -> Option<Self> { None } fn move_positive_y() -> Option<Self> { None } } }
Defining Actions (Game-Side)
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum JourneyAction { MoveLeft, MoveRight, MoveUp, MoveDown, Jump, Attack, Block, Dash, Grapple, } impl GameAction for JourneyAction { fn count() -> usize { 9 } fn index(&self) -> usize { *self as usize } fn from_index(i: usize) -> Option<Self> { match i { 0 => Some(Self::MoveLeft), 1 => Some(Self::MoveRight), 2 => Some(Self::MoveUp), 3 => Some(Self::MoveDown), 4 => Some(Self::Jump), 5 => Some(Self::Attack), 6 => Some(Self::Block), 7 => Some(Self::Dash), 8 => Some(Self::Grapple), _ => None, } } fn move_negative_x() -> Option<Self> { Some(Self::MoveLeft) } fn move_positive_x() -> Option<Self> { Some(Self::MoveRight) } fn move_negative_y() -> Option<Self> { Some(Self::MoveUp) } fn move_positive_y() -> Option<Self> { Some(Self::MoveDown) } } }
Key
#![allow(unused)] fn main() { pub enum Key { W, A, S, D, Space, Shift, Alt, Up, Down, Left, Right, F12, Escape, } }
MouseBinding
#![allow(unused)] fn main() { pub enum MouseBinding { Left, Right, Middle, } }
InputState<A> Methods
#![allow(unused)] fn main() { //? Is the action currently held down? pub fn is_action_pressed(&self, action: A) -> bool //? Was the action pressed this frame (rising edge)? pub fn is_action_just_pressed(&self, action: A) -> bool //? Was the action pressed within a recent time window? (for jump buffering) pub fn was_action_pressed_buffered(&self, action: A, buffer_window: f32) -> bool //? Raw key queries (for non-action keys like Escape) pub fn is_key_pressed(&self, key: Key) -> bool pub fn is_key_just_pressed(&self, key: Key) -> bool //? Mouse button state pub fn is_mouse_pressed(&self, button: MouseButton) -> bool //? Axis values: -1.0 to 1.0 (combines keyboard + gamepad) //? Requires move_negative_x/move_positive_x on your GameAction impl pub fn get_move_x(&self) -> f32 pub fn get_move_y(&self) -> f32 //? Device detection pub fn any_keyboard_or_mouse(&self) -> bool pub fn any_gamepad(&self) -> bool //? Access binding configuration pub fn input_map_mut(&mut self) -> &mut InputMap<A> }
InputMap<A> (Custom Bindings)
The engine ships with no default bindings. Games register their own during init():
#![allow(unused)] fn main() { pub fn bind_key(&mut self, key: Key, action: A) pub fn bind_mouse(&mut self, button: MouseBinding, action: A) //? Native only: pub fn bind_button(&mut self, button: gilrs::Button, action: A) }
Setting Up Bindings (Game-Side)
#![allow(unused)] fn main() { fn init(ctx: &mut Context<JourneyAction>) -> Self { let map = ctx.input.input_map_mut(); map.bind_key(Key::A, JourneyAction::MoveLeft); map.bind_key(Key::Left, JourneyAction::MoveLeft); map.bind_key(Key::D, JourneyAction::MoveRight); map.bind_key(Key::Right, JourneyAction::MoveRight); map.bind_key(Key::Space, JourneyAction::Jump); map.bind_mouse(MouseBinding::Left, JourneyAction::Attack); map.bind_mouse(MouseBinding::Right, JourneyAction::Block); //? ... } }
Usage from game
#![allow(unused)] fn main() { fn fixed_update(&mut self, ctx: &mut Context<JourneyAction>, fixed_time: &FixedTime) { let move_x = ctx.input.get_move_x(); //? Jump with buffering (8 ticks ≈ 133ms at 60Hz) let buffer_window = self.stats.jump_buffer_ticks as f32 / 60.0; if ctx.input.was_action_pressed_buffered(JourneyAction::Jump, buffer_window) { self.execute_jump(); } //? Dash on just-pressed (no buffering) if ctx.input.is_action_just_pressed(JourneyAction::Dash) { self.start_dash(move_x); } } }
Journey’s Key Bindings (Game-Side)
| Key | Action | Key | Action | |
|---|---|---|---|---|
| A / ← | MoveLeft | D / → | MoveRight | |
| W / ↑ | MoveUp | S / ↓ | MoveDown | |
| Space | Jump | Shift | Dash | |
| Alt | Grapple | Mouse L | Attack | |
| Mouse R | Block |
Journey’s Gamepad Bindings (Game-Side, Native)
| Button | Action |
|---|---|
| South (A/Cross) | Jump |
| West (X/Square) | Attack |
| RightTrigger (RB) | Block |
| RightTrigger2 (RT) | Dash |
| LeftTrigger2 (LT) | Grapple |
Physics
2D collision detection primitives.
Module: engine::physics
AABB
Axis-Aligned Bounding Box: the primary collision shape.
#![allow(unused)] fn main() { pub struct AABB { pub center: Vec2, pub size: Vec2, } }
Constructors
#![allow(unused)] fn main() { //? From center position and size pub fn new(center: Vec2, size: Vec2) -> Self //? From top-left corner and size (common for sprites) pub fn from_top_left(top_left: Vec2, size: Vec2) -> Self }
Queries
#![allow(unused)] fn main() { pub fn min(&self) -> Vec2 //? Top-left corner pub fn max(&self) -> Vec2 //? Bottom-right corner pub fn top_left(&self) -> Vec2 //? Alias for min() pub fn check_collision(&self, other: &AABB) -> bool pub fn get_overlap(&self, other: &AABB) -> Vec2 //? Per-axis overlap }
Collision Resolution
#![allow(unused)] fn main() { //? Minimum Translation Vector to push mover out of obstacle //? Returns None if no overlap. Pushes along smallest-overlap axis. //? Y-axis bias toward upward push (landing on platforms). pub fn resolve_collision(mover: &AABB, obstacle: &AABB) -> Option<Vec2> }
Swept Collision (CCD)
#![allow(unused)] fn main() { pub struct SweepResult { pub time: f32, //? 0.0–1.0, fraction of displacement before hit pub normal: Vec2, //? Surface normal at contact point } //? Continuous collision: move self along displacement, find earliest hit. //? Uses Minkowski-expanded ray cast. pub fn swept_collision(&self, displacement: Vec2, obstacle: &AABB) -> Option<SweepResult> }
CollisionLayer
#![allow(unused)] fn main() { pub enum CollisionLayer { Pushbox, //? Physics body (blocks movement) Hurtbox, //? Vulnerable area (takes damage) Hitbox, //? Damaging area (deals damage) Parrybox, //? Parry detection area } }
BoxVolume
A positioned collision volume relative to an entity center. Auto-flips X offset based on facing direction.
#![allow(unused)] fn main() { pub struct BoxVolume { pub layer: CollisionLayer, pub local_offset: Vec2, pub size: Vec2, pub active: bool, } pub fn new(layer: CollisionLayer, offset: Vec2, size: Vec2) -> Self pub fn world_aabb(&self, entity_pos: Vec2, facing_right: bool) -> AABB }
Usage from game
#![allow(unused)] fn main() { //? Player pushbox let player_aabb = AABB::new(player.position, Vec2::new(PLAYER_WIDTH, PLAYER_HEIGHT)); //? Check collision with each platform for platform in &platforms { if let Some(mtv) = AABB::resolve_collision(&player_aabb, &platform.aabb) { player.position += mtv; } } //? Swept collision for high-speed movement let displacement = velocity * dt; if let Some(hit) = player_aabb.swept_collision(displacement, &wall) { //? Move to contact point player.position += displacement * hit.time; //? Slide along surface let remaining = displacement * (1.0 - hit.time); let slide = remaining - hit.normal * remaining.dot(hit.normal); player.position += slide; } //? Hitbox that flips with facing direction let hitbox = BoxVolume::new( CollisionLayer::Hitbox, Vec2::new(12.0, 0.0), //? 12px in front of center Vec2::new(20.0, 24.0), ); let world_hitbox = hitbox.world_aabb(entity.position, entity.facing_right); }
Sprite and Rendering
Instanced 2D sprite rendering with sprite sheet and blend mode support.
Module: engine::sprite
Rect
#![allow(unused)] fn main() { pub struct Rect { pub x: f32, pub y: f32, pub w: f32, pub h: f32, } pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self pub fn from_pos_size(pos: Vec2, size: Vec2) -> Self }
BlendMode
#![allow(unused)] fn main() { pub enum BlendMode { Alpha, //? Standard transparency (default) Additive, //? Additive blending (effects, glow) } }
RenderLayer
#![allow(unused)] fn main() { pub enum RenderLayer { Background, World, //? Default gameplay layer Effects, //? Glows, trails, particles, additive rects Debug, //? Collision boxes and debug overlays } }
Layers are a coarse draw-order contract, not a scene system. The renderer draws layers in declaration order, and within each layer draws alpha before additive.
Sprite (Internal)
The Sprite struct is not typically created directly by game code. Instead, use the Context draw methods which build sprites internally:
#![allow(unused)] fn main() { //? Solid colored rectangle ctx.draw_rect(pos, size, color); //? Explicit layer for debug or background/world split ctx.draw_rect_layer(RenderLayer::Debug, pos, size, color); //? Additive colored rectangle for neon/effects ctx.draw_rect_additive(pos, size, color); //? Textured sprite from sheet ctx.draw_sprite_from_sheet(pos, size, color, source_rect, flip_x, texture_id); //? Additive blended sprite ctx.draw_sprite_from_sheet_additive(pos, size, color, source_rect, flip_x, texture_id); }
Rendering Details
- Up to 65_536 sprites per frame (instanced rendering)
- Sprites are ordered by
RenderLayer, thenBlendMode, then batched bytexture_id - Two render pipelines: alpha blend (default) and additive blend
- Bloom is applied during the final blit when enabled
- Horizontal flipping is done in UV-space, avoiding anchor-offset artifacts
texture_id = 0uses a built-in 1×1 white pixel (for colored rectangles)
Texture Management
Modules: engine::texture, engine::texture_manager
Texture
Low-level GPU texture. Not typically used directly, go through Context::load_texture() or TextureManager.
#![allow(unused)] fn main() { pub struct Texture { pub texture: wgpu::Texture, pub view: wgpu::TextureView, pub sampler: wgpu::Sampler, pub width: u32, pub height: u32, } pub fn from_bytes(device, queue, bytes, label) -> Result<Self, ImageError> pub fn from_image(device, queue, img, label) -> Result<Self, ImageError> pub fn white_pixel(device, queue) -> Self }
TextureHandle
Opaque handle to a loaded texture in the TextureManager.
#![allow(unused)] fn main() { pub struct TextureHandle(pub(crate) usize); }
Loading Textures in game
The simplest path is Context::load_texture() during init:
#![allow(unused)] fn main() { fn init(ctx: &mut Context) -> Self { //? Returns a 1-based ID used with draw_sprite_from_sheet let tex_player = ctx.load_texture( include_bytes!("../assets/player/player.png"), "Player Spritesheet", ); //? tex_player == 1 let tex_effects = ctx.load_texture( include_bytes!("../assets/effects/particles.png"), "Effects Sheet", ); //? tex_effects == 2 } }
Camera and Screen Shake
Module: engine::camera
ScreenShake
Decaying sinusoidal screen shake with Lissajous-like orbits for organic feel.
#![allow(unused)] fn main() { pub struct ScreenShake { pub intensity: f32, pub duration: f32, pub frequency: f32, //? Default: 40.0 pub decay: f32, //? Default: 8.0 pub elapsed: f32, } pub fn new(intensity: f32, duration: f32) -> Self pub fn sample(&self) -> Vec2 //? Current shake offset pub fn update(&mut self, dt: f32) -> bool //? Returns true if still active pub fn is_active(&self) -> bool }
Triggering Shakes from game
#![allow(unused)] fn main() { //? Via Context (most common) ctx.trigger_shake(4.0, 0.15); //? intensity=4.0, duration=0.15s //? Multiple shakes stack additively ctx.trigger_shake(2.0, 0.1); //? Light shake ctx.trigger_shake(8.0, 0.3); //? Heavy shake (both run simultaneously) }
Camera (Internal)
The camera is managed by the engine runtime. Game code interacts with it via Context fields:
#![allow(unused)] fn main() { //? Read camera state let offset_x = ctx.camera_offset_x; let offset_y = ctx.camera_offset_y; //? Camera follow is typically done in update(): fn update(&mut self, ctx: &mut Context) { let target_x = (self.player.position().x - ctx.screen_width / 2.0).max(0.0); self.camera_x = lerp(self.prev_camera_x, target_x, 0.1); ctx.camera_offset_x = self.camera_x; ctx.camera_offset_y = self.camera_y; } }
Audio
Cross-platform audio system wrapping Kira with lazy WASM initialization.
Module: engine::audio
AudioTrack
#![allow(unused)] fn main() { pub enum AudioTrack { Music, //? Looping background music Ambience, //? Looping ambient sounds Sfx, //? Sound effects (one-shot + looping) Ui, //? UI sounds (one-shot) } }
UiAudioEvent
Engine-level UI audio events (game-specific events are defined by the game crate):
#![allow(unused)] fn main() { pub enum UiAudioEvent { Hover, Click, CheckboxOn, CheckboxOff, TabChange, } }
AudioManager Methods
Playback
#![allow(unused)] fn main() { //? One-shot sound on a specific track pub fn play_oneshot(&mut self, data: &StaticSoundData, track: AudioTrack) //? Looping music with crossfade pub fn play_music(&mut self, data: &StaticSoundData, fade_in_secs: f32) pub fn stop_music(&mut self, fade_out_secs: f32) //? Looping ambience with crossfade pub fn play_ambience(&mut self, data: &StaticSoundData, fade_in_secs: f32) pub fn stop_ambience(&mut self, fade_out_secs: f32) //? Looping SFX (e.g., footstep loop while running) pub fn play_loop_sfx(&mut self, data: &StaticSoundData) pub fn stop_loop_sfx(&mut self, fade_out_secs: f32) }
Volume Control
#![allow(unused)] fn main() { //? Track-level volume (0.0–1.0) pub fn set_master_volume(&mut self, volume: f64) pub fn set_music_volume(&mut self, volume: f64) pub fn set_ambience_volume(&mut self, volume: f64) pub fn set_sfx_volume(&mut self, volume: f64) pub fn set_ui_volume(&mut self, volume: f64) //? Read current volume pub fn master_volume(&self) -> f64 pub fn music_volume(&self) -> f64 //? ... (same pattern for other tracks) //? Computed effective volume (master × track) pub fn effective_volume(&self, track: AudioTrack) -> f64 //? Live ducking with fade pub fn set_music_live_volume(&mut self, amp: f64, fade_secs: f32) pub fn set_ambience_live_volume(&mut self, amp: f64, fade_secs: f32) }
State Queries
#![allow(unused)] fn main() { pub fn has_active_music(&self) -> bool pub fn has_active_ambience(&self) -> bool pub fn notify_user_gesture(&mut self) //? WASM: unlock Web Audio context }
Loading Sounds
#![allow(unused)] fn main() { //? Decode sound from embedded bytes (cross-platform) pub fn load_sound_data(bytes: &'static [u8]) -> Option<StaticSoundData> }
AudioResponse Trait (egui Integration)
#![allow(unused)] fn main() { pub trait AudioResponse { fn with_ui_sound(self, pending: &mut Vec<UiAudioEvent>) -> Self; fn with_checkbox_sound(self, checked: bool, pending: &mut Vec<UiAudioEvent>) -> Self; fn with_tab_sound(self, pending: &mut Vec<UiAudioEvent>) -> Self; } }
Usage from game
#![allow(unused)] fn main() { //? Loading audio assets at init struct AudioAssets { sfx_jump: Option<StaticSoundData>, bg_music: Option<StaticSoundData>, } impl AudioAssets { fn load() -> Self { Self { sfx_jump: load_sound_data(include_bytes!("../assets/audio/sfx_jump.ogg")), bg_music: load_sound_data(include_bytes!("../assets/audio/bg_music.ogg")), } } } //? Playing music (in update) if let Some(ref data) = self.audio_assets.bg_music { ctx.audio.play_music(data, 1.0); //? 1s fade-in } //? Game-specific audio: define your own enum and queue pub enum AudioEvent { Jump, Dash, Hit, /* ... */ } let mut pending_game_audio: Vec<AudioEvent> = Vec::new(); pending_game_audio.push(AudioEvent::Jump); //? Dispatch game events (in update) for event in pending_game_audio.drain(..) { self.audio_assets.dispatch(event, &mut ctx.audio); } //? Dispatch engine UI events for ui_event in ctx.pending_ui_audio.drain(..) { self.audio_assets.dispatch_ui(ui_event, &mut ctx.audio); } //? Music ducking during menus let ducked_vol = ctx.audio.effective_volume(AudioTrack::Music) * 0.3; ctx.audio.set_music_live_volume(ducked_vol, 0.4); //? egui UI sounds (push UiAudioEvent automatically) ui.button("Start") .with_ui_sound(&mut ctx.pending_ui_audio) .clicked(); ui.checkbox(&mut show_fps, "Show FPS") .with_checkbox_sound(show_fps, &mut ctx.pending_ui_audio); }
Animation
Asset-agnostic animation runtime. The engine provides the state machine; the game maps frames to textures.
Module: engine::animation
AnimationDef
#![allow(unused)] fn main() { pub struct AnimationDef { pub name: String, pub start_frame: usize, pub frame_count: usize, pub frame_duration: f32, //? Seconds per frame pub looping: bool, } pub fn new( name: impl Into<String>, start_frame: usize, frame_count: usize, frame_duration: f32, looping: bool, ) -> Self }
AnimationState
#![allow(unused)] fn main() { pub struct AnimationState { pub current_anim: String, pub frame_index: usize, pub timer: f32, } pub fn new(animations: Vec<AnimationDef>, default_anim: &str) -> Self pub fn update(&mut self, dt: f32) pub fn current(&self) -> Option<(&AnimationDef, usize)> pub fn play(&mut self, anim_name: &str) //? Switch animation (resets frame/timer) pub fn is_finished(&self) -> bool //? Non-looping anim reached last frame? pub fn current_animation_name(&self) -> Option<&str> pub fn get_progress(&self) -> f32 //? 0.0–1.0 through current animation }
Usage from game
The game wraps the engine's AnimationState with spritesheet-specific logic:
#![allow(unused)] fn main() { //? Define animations with grid-based start frames let animations = vec![ Animation::new("Idle", 0, 4, 0.12, true), //? Row 0, 4 frames, looping Animation::new("Run", 5, 4, 0.08, true), //? Row 1, 4 frames, looping Animation::new("Jump", 10, 3, 0.1, false), //? Row 2, 3 frames, one-shot Animation::new("Fall", 13, 2, 0.1, true), //? Row 2 continued Animation::new("Death", 15, 4, 0.1, false), //? Row 3, one-shot ]; let mut anim_state = AnimationState::new(animations, "Idle"); //? In fixed_update: switch based on player state match player.state { PlayerState::Idle => anim_state.play("Idle"), PlayerState::Run => anim_state.play("Run"), PlayerState::Jump => anim_state.play("Jump"), PlayerState::Fall => anim_state.play("Fall"), _ => {} } //? In update: advance timer anim_state.update(ctx.delta_time); //? In render: get the current animation def and frame index if let Some((anim_def, frame_idx)) = anim_state.current() { let sprite_frame = anim_def.start_frame + frame_idx; let col = sprite_frame % SHEET_COLS; let row = sprite_frame / SHEET_COLS; let frame_rect = Rect::new(col as f32 * FRAME_W, row as f32 * FRAME_H, FRAME_W, FRAME_H); ctx.draw_sprite_from_sheet( position, size, [1.0, 1.0, 1.0, 1.0], frame_rect, !facing_right, texture_id, ); } //? Combat animations derive frame duration from FSM data let move_data = move_db.get_base(MoveId::AttackHorizontal); let frame_dur = move_data.anim_frame_duration(4); //? 4 sprite frames Animation::new("AttackHorizontal", 25, 4, frame_dur, false); }
Time
Fixed-timestep timing system for deterministic game logic.
Module: engine::time
Constants
#![allow(unused)] fn main() { pub const DEFAULT_FIXED_HZ: u32 = 60; pub const MAX_STEPS: u32 = 5; //? Spiral-of-death cap }
FixedTime
#![allow(unused)] fn main() { pub struct FixedTime { pub tick: u64, //? Monotonic tick counter pub fixed_dt: f32, //? Duration of one fixed step } pub fn new(hz: u32) -> Self pub fn accumulate(&mut self, dt: f32) -> u32 //? Returns number of steps to run pub fn advance(&mut self) //? Consume one step, increment tick pub fn interpolation_alpha(&self) -> f32 //? Leftover fraction for smoothing pub fn tick_rate(&self) -> u32 pub fn set_tick_rate(&mut self, hz: u32) pub fn freeze(&mut self, frames: u16) //? Pause physics for N frames pub fn is_frozen(&self) -> bool pub fn freeze_remaining(&self) -> u16 }
Usage from game
FixedTime is managed by the engine and passed to fixed_update():
#![allow(unused)] fn main() { fn fixed_update(&mut self, ctx: &mut Context, fixed_time: &FixedTime) { //? Use tick for deterministic frame-data combat let current_tick = fixed_time.tick; self.player.current_tick = current_tick; //? Use fixed_dt for physics integration entity.velocity.y += GRAVITY * fixed_time.fixed_dt; entity.position += entity.velocity * fixed_time.fixed_dt; //? Hitstop via freeze ctx.trigger_freeze(5); //? 5-frame pause on a heavy hit } }
Math Utilities
Module: engine::math
move_towards
#![allow(unused)] fn main() { pub fn move_towards(current: f32, target: f32, max_delta: f32) -> f32 }
Move current toward target by at most max_delta. Snaps exactly to target if within max_delta, avoiding overshooting. Mirrors Unity's Mathf.MoveTowards.
#![allow(unused)] fn main() { use engine::math::move_towards; //? Smooth deceleration velocity_x = move_towards(velocity_x, 0.0, GROUND_DECEL * dt); //? Smooth acceleration toward target speed velocity_x = move_towards(velocity_x, target_speed, ACCELERATION * dt); }
Atmosphere and Scene
Procedural sky and fog rendering.
Module: engine::atmosphere
SkyParams
#![allow(unused)] fn main() { pub struct SkyParams { pub enabled: bool, pub horizon_glow: f32, pub top_color: [f32; 3], pub horizon_color: [f32; 3], pub bottom_color: [f32; 3], pub horizon_y: f32, pub horizon_width: f32, } }
SkyParams controls the gradient sky primitive. The engine defaults this on for Journey's game scene and options UI, replacing the older solid background_color path for normal gameplay. The old background color remains as a fallback for explicit overrides such as benchmark scenes.
SkyParams also exposes a lerp method for transitions:
#![allow(unused)] fn main() { impl SkyParams { pub fn lerp(&self, other: &SkyParams, t: f32) -> SkyParams; } }
SkyTransition
#![allow(unused)] fn main() { pub struct SkyTransition { pub current: SkyParams, pub target: SkyParams, pub duration: f32, pub elapsed: f32, } }
A one-shot transition helper that interpolates from current to target sky over duration seconds.
#![allow(unused)] fn main() { let mut t = SkyTransition::new(day_sky, night_sky, 2.0); t.advance(dt); let params = t.lerp(); //* returns interpolated SkyParams at current progress if t.done() { /* transition complete */ } }
SceneParams
#![allow(unused)] fn main() { pub struct SceneParams { pub background_color: [f32; 3], //? Fallback RGB (0.0–1.0) pub sky: SkyParams, pub seed: u32, pub fog_enabled: bool, pub fog_density: f32, pub fog_opacity: f32, pub fog_color: [f32; 3], pub fog_anim_speed: f32, pub time: f32, } }
SceneParams is passed to GameApp::ui() so games can expose debug or options controls. The runtime renders two 32x32 CPU-generated atmosphere textures:
- Sky texture: gradient/horizon data, sampled linearly by the fullscreen shader so the sky blends smoothly.
- Fog texture: Perlin fog overlay with alpha coverage, sampled with nearest filtering so fog keeps the chunky retro look.
The fullscreen background shader composites sky.rgb with fog.rgb using fog.a, then world sprites render on top at the game's internal resolution.
Atmosphere Functions
#![allow(unused)] fn main() { pub fn hex_to_rgb(hex: &str) -> (u8, u8, u8) pub fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 pub fn draw_gradient(buffer, width, height, top_rgb, bot_rgb) pub fn render_sky_to_buffer(buffer, width, height, params) pub fn render_fog_overlay(buffer, width, height, time, density, opacity, perlin, seed, fog_rgb, anim_speed) pub fn render_fog_to_buffer(buffer, width, height, params, fog_noise_cache) pub fn render_atmosphere_to_buffer(buffer, width, height, params, fog_noise_cache) }
These are called internally by the engine render loop. render_atmosphere_to_buffer remains as a CPU-composited helper for tests and non-GPU callers.
Re-exports
The engine's lib.rs re-exports commonly used types for ergonomic imports:
#![allow(unused)] fn main() { //? Instead of engine::context::Context, just use: use engine::Context; //? All re-exports: pub use audio::{AudioManager, AudioResponse, AudioTrack, UiAudioEvent, load_sound_data}; pub use camera::ScreenShake; pub use context::Context; pub use glam::{Vec2, Vec3, Vec4}; pub use input::{GameAction, InputMap, InputState, Key, MouseBinding}; pub use kira::sound::static_sound::StaticSoundData; pub use math::move_towards; pub use physics::{AABB, BoxVolume, CollisionLayer, SweepResult}; pub use sprite::{BlendMode, Rect, RenderLayer}; pub use BloomSettings; pub use SkyParams; pub use SkyTransition; pub use SceneParams; pub use animation::{AnimationDef, AnimationState}; pub use texture::Texture; pub use texture_manager::TextureHandle; pub use time::FixedTime; //? Re-exported dependencies for direct use by game crates: pub use egui; #[cfg(not(target_arch = "wasm32"))] pub use gilrs; }
Entry Points
#![allow(unused)] fn main() { //? Native: blocks on async GPU init pub fn run<G: GameApp>() //? WASM: non-blocking entry pub fn run_wasm<G: GameApp>() }
Journey Engine Technical Documentation
Version: 1.1.2 · Edition: Rust 2024 · License: MIT Repository: github.com/ujjwalvivek/journey
Table of Contents
- Overview
- Architecture
- Workspace Layout
- Build Targets
- Core Engine Systems
- Game Layer
- Game Loop and Timing Model
- Rendering Pipeline
- Physics and Collision
- Input System
- Audio System
- Animation System
- Combat System
- Level System
- Configuration and Tuning
- Cross-Platform Strategy
- Performance Budget
- Dependency Map
Overview
Journey Engine is a custom high-performance 2D game engine written in Rust and wGPU. It targets both native desktop (Vulkan/Metal/DX12) and WebAssembly (WebGL/WebGPU) from a single codebase. The engine is purpose-built for a fast-momentum Metroidvania tech demo that prioritizes tight, deterministic, arcade-style physics over realistic simulation.
The project ships as a Cargo workspace with two crates, a reusable journey-engine library (published as journey-engine on crates.io) and a game binary, plus a Vite-based web project for WASM distribution. This separation enforces a clean API boundary: the engine owns rendering, input infrastructure, audio, physics primitives, and the game loop; the game owns content, gameplay logic, level design, input action definitions, and asset definitions.
Architecture
┌─────────────────────────────────────────────────────────┐
│ game (binary crate) │
│ Player · Enemies · Combat FSM · Levels · Assets · UI │
├─────────────────────────────────────────────────────────┤
│ engine (library crate) │
│ GameApp trait · Context · Renderer · Input · Physics │
│ Audio · Animation · Camera · Texture · Time · Atmosphere│
├─────────────────────────────────────────────────────────┤
│ Platform Abstraction Layer │
│ wgpu (GPU) · winit (Windowing) · kira (Audio) │
│ gilrs (Gamepad, native-only) · egui (Debug UI) │
└─────────────────────────────────────────────────────────┘
Design philosophy: Data-oriented, trait-driven, and deliberately narrow. Games implement the GameApp trait to hook into the engine's lifecycle. The engine calls init → fixed_update → update → render → ui each frame. All mutable engine access flows through a single Context struct.
Workspace Layout
Journey/
├── Cargo.toml # Workspace root (members: engine, game)
├── engine/ # Reusable library - the product
│ ├── Cargo.toml # wgpu, winit, kira, egui, glam, bytemuck
│ ├── assets/shaders/ # WGSL shaders (sprite, atmosphere)
│ └── src/
│ ├── lib.rs # Public API surface, GameApp trait, re-exports
│ ├── runtime.rs # Event loop, wGPU init, render orchestration
│ ├── context.rs # Context struct passed to GameApp methods
│ ├── input.rs # Action-based input (keyboard, mouse, gamepad)
│ ├── physics.rs # AABB, swept collision, collision layers
│ ├── sprite.rs # Instanced sprite renderer, blend modes
│ ├── texture.rs # GPU texture loading
│ ├── texture_manager.rs # Texture asset manager with handles
│ ├── camera.rs # Orthographic camera, screen shake
│ ├── audio.rs # Kira wrapper, sub-tracks, events
│ ├── animation.rs # Asset-agnostic animation state machine
│ ├── time.rs # Fixed-timestep accumulator
│ ├── math.rs # Shared math helpers (move_towards)
│ └── atmosphere.rs # Sky gradient and Perlin fog atmosphere buffers
├── game/ # Executable - the content
│ ├── Cargo.toml # Depends on journey-engine (aliased as engine)
│ ├── assets/ # Sprites, audio, levels
│ ├── pkg/ # WASM build artifacts (wasm-pack output)
│ └── src/
│ ├── main.rs # Native entry point
│ ├── lib.rs # JourneyGame: GameApp implementation, game states
│ ├── input.rs # JourneyAction enum and key/gamepad bindings
│ ├── player.rs # Player controller FSM, input handling
│ ├── enemy.rs # Enemy AI (Grunt, Sniper, Ronin)
│ ├── entity.rs # Shared Entity struct (physics + combat)
│ ├── combat/ # Frame-data FSM, input buffer, move database
│ ├── projectile.rs # Projectile pool and collision
│ ├── level.rs # ASCII-based level generation
│ ├── level_editor.rs # In-game level editor (F12)
│ ├── anim.rs # Spritesheet animation definitions
│ ├── assets.rs # Player animation grid mappings
│ ├── audio.rs # Audio asset loading and dispatch
│ ├── config.rs # Physics constants and tuning
│ ├── scene.rs # egui debug overlay
│ └── start_sequence.rs # Splash, menus, options UI
└── web/ # Vite project for WASM distribution
├── src/ # JavaScript glue code
├── public/ # Static assets (favicon)
└── vite.config.ts # WASM bundling config
Build Targets
Native (Desktop)
cargo run --bin journey # Debug
cargo run --bin journey --release # Release
Uses pollster to block on async wGPU initialization. Gamepad support via gilrs. Window title and icon are configured by the game's GameApp implementation (Journey loads its icon from web/public/favicon.png via GameApp::window_icon()).
WebAssembly (Browser)
wasm-pack build game --target web # Build WASM artifacts to game/pkg/
cd web && npm install && npm run dev # Start Vite dev server
Uses wasm-bindgen-futures for async GPU init. console_error_panic_hook pipes Rust panics to browser DevTools. Audio requires a user gesture to unlock the Web Audio API context.
Internal Resolution
All gameplay renders to a configurable internal-resolution offscreen buffer (default 640×360, set via GameApp::internal_resolution()), upscaled with nearest-neighbor filtering for a retro pixel aesthetic. The CPU atmosphere pass operates at a fixed 32×32 simulation resolution.
Core Engine Systems
The engine exposes functionality through a small set of public modules, all re-exported from engine::lib.rs for ergonomic imports:
| Module | Purpose |
|---|---|
context | Context struct - the single handle games use to interact with the engine |
input | Action-based input mapping (keyboard, mouse, gamepad) |
physics | AABB collision detection, swept CCD, collision layers |
sprite | Instanced GPU sprite renderer with sheet and blend support |
texture / texture_manager | GPU texture loading and handle-based management |
camera | Orthographic camera with screen shake |
audio | Cross-platform audio (Kira) with sub-tracks and events |
animation | Generic animation state machine (asset-agnostic) |
time | Fixed-timestep accumulator with freeze support |
math | Shared math utilities |
atmosphere | Sky gradient and Perlin fog atmosphere rendering |
Game Layer
The game crate implements GameApp for JourneyGame and owns all content:
Game State Machine
#![allow(unused)] fn main() { enum GameState { Splash { timer: f32 }, StartMenu { animation_progress: f32 }, Options { return_state, tab }, LevelEditor { return_state }, Benchmark, InGame, Paused, } }
State transitions drive music changes, input routing, and UI visibility. The splash screen is skipped on WASM builds.
Shared Entity Model
Journey uses direct composition. Actors such as the player and enemies share an Entity struct containing:
- Position and velocity (
Vec2) - Facing direction
- Pushbox and hurtbox sizes (
AABB) - Combat state (
CombatState) - Health
System functions like fixed_update_physics() and integrate_and_collide() operate on &mut Entity generically. This keeps the hot gameplay path explicit and simple while still avoiding duplicated movement, collision, and combat state across actor types.
Player Controller
The player is a state-machine-driven controller with states: Idle, Run, Jump, Fall, Dash, AirDash, Parry, AttackHorizontal, AttackUp, AttackDown, WallGrab, WallSlide, GrapplePull, GrappleSlingshot, Death. Each state handles its own movement, transitions, coyote timing, jump buffering, and animation selection.
Combat input is sampled every render frame but consumed in fixed_update() via a tick-stamped CombatInputBuffer for deterministic FSM synchronization.
Enemy System
Three enemy types (Grunt, Sniper, Ronin) share an FSM but differ via a data-driven EnemyConfig table. Core mechanic chain: Enemy shoots → Player parries → Enemy staggers → Player grapples → Execute.
Game Loop and Timing Model
The engine uses a fixed-timestep with interpolation model:
Each Frame:
1. Measure wall-clock delta time (capped at 100ms)
2. Feed delta into FixedTime accumulator
3. Run N fixed_update() steps at fixed_dt (default 1/60s)
- Cap at MAX_STEPS=5 to prevent spiral of death
4. Compute interpolation_alpha for render smoothing
5. Call update() with variable delta_time
6. Call render() for sprite submission
7. Call ui() for egui overlay
8. GPU: upload sprites, draw background + sprites + egui
FixedTime
tick: Monotonic 64-bit counter, incremented once per fixed step.fixed_dt: Duration of one fixed step (default1/60s).accumulator: Leftover wall-clock time carried between frames.freeze_frames: When > 0, accumulator is not fed and no fixed steps run. Decrements once per render frame. Used for hitstop and impact freeze.
Interpolation
interpolation_alpha = accumulator / fixed_dt gives a 0.0–1.0 fraction for render-time smoothing between the previous and current physics positions. This ensures silky-smooth visuals even at high refresh rates while keeping physics deterministic.
Rendering Pipeline
┌─────────────────────────────────────────────┐
│ CPU Atmosphere Pass (32×32) │
│ Sky gradient texture + fog overlay texture│
├─────────────────────────────────────────────┤
│ Full-Screen Quad (background) │
│ Linear sky sample + nearest fog sample │
├─────────────────────────────────────────────┤
│ Instanced Sprite Pass (640×360) │
│ RenderLayer → Alpha → Additive │
│ Batched by texture_id, for 65,536 sprites │
├─────────────────────────────────────────────┤
│ Bloom Composite / Final Blit │
│ Bright-pass neighborhood glow + upscale │
├─────────────────────────────────────────────┤
│ egui Overlay Pass │
│ Debug UI, menus, HUD │
└─────────────────────────────────────────────┘
Atmosphere Rendering
The background is not a scene graph or entity system. It is a small CPU-generated atmosphere pass built from SceneParams:
SkyParamsproduces the base sky gradient with top, horizon, and bottom colors.- Fog is generated into a separate Perlin overlay texture where RGB stores fog color and alpha stores coverage.
- Both textures stay at 32×32.
- The fullscreen shader samples the sky texture with linear filtering, then samples the fog texture with nearest filtering and composites
mix(sky.rgb, fog.rgb, fog.a).
This split keeps the sky blended while preserving the blocky legacy fog style. Gameplay sprites still render later into the internal 640×360 scene and remain nearest-upscaled.
Sprite Rendering
Sprites are submitted via ctx.draw_sprite(), ctx.draw_rect(), and ctx.draw_sprite_from_sheet() during render(). They are collected into a Vec<Sprite>, partitioned by coarse RenderLayer, then by BlendMode, converted to SpriteInstance GPU data, batched by texture ID, and rendered with instanced draw calls.
The sprite layer contract is intentionally small:
- Background: optional sprite-backed background elements above the atmosphere pass
- World: default gameplay sprites and solid geometry
- Effects: glows, trails, particles, and additive rectangles
- Debug: collision boxes and diagnostic overlays
This is a render-order contract only. It is not a scene graph, ownership model, or ECS substitute.
Two render pipelines exist within the same render pass:
- Alpha blend (default): Standard transparency
- Additive blend: For effects like hit flashes, trails, projectile glow, and neon rectangles
Additive blending brightens existing pixels. Bloom is the separate post-process that makes those bright pixels bleed into neighboring pixels. The engine exposes this through BloomSettings, either persistently through ctx.bloom or for a single frame with ctx.override_bloom(...).
The current bloom pass is intentionally small: it runs during the final blit from the internal render target to the window surface, keeps the source image crisp with textureLoad, extracts bright pixels above a threshold, samples a compact neighborhood, and composites the glow back over the scene. It is designed for neon/pixel-art styling, not physically accurate lighting.
Horizontal sprite flipping is done in UV-space (not scale-space) to eliminate anchor-offset artifacts.
Camera
An orthographic camera maps pixel coordinates to NDC space. It supports:
- Horizontal and vertical panning (camera follow)
- Additive screen shakes with exponential decay and Lissajous-like orbits
- Resize handling for window/canvas changes
Physics and Collision
AABB (Axis-Aligned Bounding Box)
The primary collision primitive. Constructed from center + size or top-left + size:
#![allow(unused)] fn main() { let box_a = AABB::new(center, size); let box_b = AABB::from_top_left(top_left, size); }
Operations:
check_collision(): Boolean overlap testget_overlap(): Overlap amount per axisresolve_collision(): Minimum translation vector (MTV) to separate two boxesswept_collision(): Continuous collision detection via Minkowski-expanded ray cast
Collision Layers
#![allow(unused)] fn main() { enum CollisionLayer { Pushbox, Hurtbox, Hitbox, Parrybox } }
BoxVolume pairs a CollisionLayer with a local offset and size, and generates world-space AABBs that auto-flip based on facing direction.
Integration
Physics integration uses decoupled X/Y swept passes to prevent the "floor catch" bug where combined diagonal displacement enters a tile from the wrong axis. One-way platforms only resolve downward collisions.
A skin-width constant prevents the floor the player stands on from being detected as a side wall during the X-sweep.
Input System
Action-Based Design
The engine provides a generic GameAction trait. Each game defines its own action enum and registers key/gamepad bindings at init time, the engine contains no hardcoded actions or default bindings:
#![allow(unused)] fn main() { //? Game-side action definition (e.g. game/src/input.rs) #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum JourneyAction { MoveLeft, MoveRight, MoveUp, MoveDown, Jump, Attack, Block, Dash, Grapple, } impl engine::GameAction for JourneyAction {} }
Bindings are registered in GameApp::init() via InputMap<A>:
#![allow(unused)] fn main() { ctx.input.map.bind_key(Key::ArrowLeft, JourneyAction::MoveLeft); ctx.input.map.bind_key(Key::Space, JourneyAction::Jump); //? Gamepad (native only) ctx.input.map.bind_gamepad_button(gilrs::Button::South, JourneyAction::Jump); ctx.input.map.bind_gamepad_axis_negative(gilrs::Axis::LeftStickX, JourneyAction::MoveLeft); }
Game logic queries actions through the generic InputState<A>, never raw keys:
#![allow(unused)] fn main() { if ctx.input.is_action_just_pressed(JourneyAction::Jump) { ... } if ctx.input.was_action_pressed_buffered(JourneyAction::Jump, buffer_window) { ... } let move_x = ctx.input.get_move_x(); //* -1.0 to 1.0 }
Input Buffering
Both movement and combat inputs support buffering. Movement uses was_action_pressed_buffered() with a configurable time window. Combat uses a dedicated CombatInputBuffer with tick-stamped entries consumed by the FSM.
Platform Support
- Native: Keyboard + mouse + gamepad (via
gilrs) - WASM: Keyboard + mouse only (gamepad planned)
Audio System
Architecture
The audio pipeline supports two distinct paradigms: Static Audio (sample playback) and Procedural Audio (generative synthesis).
Static Audio (Kira)
For traditional .wav/.ogg playback, the engine wraps kira with lazy initialization (required on WASM for Web Audio API gesture requirements):
AudioManager
├── Music track (looping, crossfade)
├── Ambience track (looping, crossfade)
├── SFX track (one-shot + looping)
└── UI track (one-shot)
Each track has independent volume control. Music and ambience use handle tracking to prevent overlapping loops. All sounds are embedded via include_bytes!() and decoded at init time for cross-platform compatibility.
Procedural Audio (Resonance & Cadence)
To support dynamic, endless, and generative audio without inflating binary size with megabytes of static files, the ecosystem includes a custom, zero-allocation procedural audio stack built as independent crates:
- Resonance (
no_stdDSP Primitive): A pure-math audio synthesis primitive. It generates waveforms (Sine, Square, Triangle, Sawtooth) via compile-time generated LUTs (viabuild.rs), eliminating the need forstd::f32::sin()and enabling bare-metal usage. It features a programmable ADSR envelope system and handles PCM buffer filling entirely using fixed-pointu32phase accumulators. - Cadence (
no_stdSequencer): The mathematical logic core sitting above Resonance. It tracks time accurately via discrete audio sample counting (guaranteeing zero drift indefinitely) and executes deterministic algorithms like Euclidean Rhythms (Bjorklund's algorithm) and Markov Chains (via an LFSR) to trigger real-time events.
Decoupled Sinks: Both Resonance and Cadence are pure state machines completely unaware of their audio sink. In native builds, they can be fed to a cpal audio driver. On the web, they compile to a tiny wasm32-unknown-unknown blob running directly inside an AudioWorkletNode, remaining perfectly phase-locked and executing completely off the main thread.
Event-Driven SFX
The engine provides UiAudioEvent for UI sounds (hover, click, checkbox, tab change), managed via ctx.pending_ui_audio and deduplicated per frame. Game-specific audio events (jump, dash, hit, etc.) are defined and queued entirely by the game crate, keeping the engine free of game-specific coupling.
#![allow(unused)] fn main() { //? Engine UI events (automatic via AudioResponse trait on egui widgets) ui.button("Attack").with_ui_sound(&mut ctx.pending_ui_audio); //? Game events (game-defined enum, game-managed queue) self.pending_game_audio.push(AudioEvent::Jump); }
Animation System
Two-Layer Design
-
Engine layer (
engine::animation): Asset-agnosticAnimationDef+AnimationStateruntime. Handles frame timing, playback, looping, and state transitions. Does not know about textures. -
Game layer (
game::anim):Animationstruct that wrapsAnimationDefwith spritesheet-specific logic (grid-basedget_frame_rect()).AnimationStatewraps the engine's state machine and adds frame rectangle calculation.
Spritesheet Layout
The player spritesheet uses a 5-column × 11-row grid at 100×100px per cell:
| Row | Frames | Animation |
|---|---|---|
| 0 | 0–3 | Idle |
| 1 | 5–8 | Run |
| 2 | 10–14 | Jump (10–12 ascend, 13–14 fall) |
| 3 | 15–18 | Death |
| 4 | 20–23 | Parry |
| 5 | 25–28 | Attack Horizontal |
| 6 | 30–33 | Attack Up |
| 7 | 35–38 | Attack Down |
| 8 | 40–43 | Dash |
| 9 | 45–47 | Wall Grab/Slide |
| 10 | 50–51 | Grapple |
Combat animation durations are dynamically derived from FSM frame data so visual playback stays locked to combat timing.
Combat System
Frame-Data FSM
All combat timing uses integer tick counts at the fixed tick rate (default 60Hz). Each move is defined by three phases:
Startup → Active → Recovery
MoveData stores frame counts for each phase, plus damage, knockback, hitbox geometry, and a cancel window percentage. The FSM advances one tick per fixed_update() call and auto-transitions between phases.
Moves
#![allow(unused)] fn main() { enum MoveId { AttackHorizontal, AttackUp, AttackDown, Parry, Dash, Grapple, } }
MoveDatabase holds all move definitions and supports runtime tick-rate scaling. Frame counts are proportionally adjusted so wall-clock timing stays consistent across different tick rates.
Cancel Windows
The last N% of recovery allows cancelling into another move, enabling combo chains. The cancel window percentage is per-move configurable.
Combat Input Buffer
Bridges per-frame input sampling and fixed-rate FSM updates. Inputs are queued with tick stamps and consumed within a configurable frame window (default 20 ticks ≈ 333ms at 60Hz), ensuring rapid inputs are never lost between fixed steps.
Dash Invincibility
Dash grants i-frames during the first half of its active phase, handled within the combat FSM.
Level System
ASCII-Based Level Definition
Levels are defined as ASCII strings where each character maps to a game element:
| Char | Element |
|---|---|
# | Solid platform |
= | One-way platform |
| | Wall |
@ | Player spawn |
G | Grunt enemy |
S | Sniper enemy |
R | Ronin enemy |
O | Grapple node |
X | Exit |
Level Editor
Press F12 to toggle a full-screen dual-mode editor operating on a canonical ASCII string. Features:
- Live minimap with color-coded elements
- Validation pass warning on missing elements
- Universal persistence:
world.txton native,localStorageon WASM
Configuration and Tuning
All physics constants are centralized in game/src/config.rs using a PIXELS_PER_UNIT base (16px) for dimensional consistency:
| Category | Key Constants |
|---|---|
| Movement | MOVEMENT_SPEED (300px/s), MAX_SPEED (300px/s) |
| Jump | JUMP_POWER (600px/s), COYOTE_TICKS (6), JUMP_BUFFER_TICKS (8) |
| Gravity | GRAVITY (1760px/s²), MAX_FALL_SPEED (640px/s) |
| Dash | DASH_SPEED (800px/s), DASH_DURATION_TICKS (8), DASH_COOLDOWN_TICKS (10) |
| Wall | WALL_SLIDE_SPEED (120px/s), WALL_GRAB_TIMEOUT_TICKS (10) |
| Grapple | GRAPPLE_PULL_SPEED (400px/s), GRAPPLE_DETECT_RANGE (144px) |
A runtime-tunable PhysicsConfig struct mirrors these constants and can be modified via the egui debug UI during development.
Cross-Platform Strategy
Conditional Compilation
#![allow(unused)] fn main() { #[cfg(not(target_arch = "wasm32"))] //* Native-only code #[cfg(target_arch = "wasm32")] //* WASM-only code }
Key platform differences:
| Concern | Native | WASM |
|---|---|---|
| GPU init | pollster::block_on (sync) | wasm_bindgen_futures::spawn_local (async) |
| Gamepad | gilrs | Not available |
| Audio init | Immediate at startup | Deferred until user gesture |
| Logging | env_logger | console_log + console_error_panic_hook |
| Fullscreen | winit::Fullscreen | CSS 100vw × 100vh |
| Visual FPS cap | Native sleep/WaitUntil cap | Browser RAF/vsync |
| Level persistence | File I/O (world.txt) | web_sys::Storage (localStorage) |
| Splash screen | 2s timer | Skipped |
| Clipboard | OS clipboard via arboard | Not available |
WASM Dependencies
wasm-bindgen/wasm-bindgen-futures: JS interop and async supportweb-sys: DOM manipulation (canvas, storage, window)console_error_panic_hook: Panic messages in DevToolsconsole_log: Rustlogfacade to browser consolegetrandomwithjsfeature: Crypto RNG forrand
Performance Budget
| Metric | Target |
|---|---|
| Frame rate | 60 FPS stable (native + browser) |
| WASM binary | < 5 MB (gzip) |
| Sprite cap | 65,536 instanced sprites per frame |
| Physics steps | Max 5 per frame (spiral-of-death cap) |
| Atmosphere resolution | 32×32 CPU sky/fog pass at ~60Hz |
| Internal resolution | Default 640×360 (configurable via GameApp), nearest-neighbor upscale |
Dependency Map
Engine Crate
| Dependency | Purpose |
|---|---|
wgpu 27 | Cross-platform GPU abstraction |
winit 0.30 | Windowing and event loop |
egui 0.33 / egui-wgpu / egui-winit | Immediate-mode debug UI |
kira 0.12 | Cross-platform audio engine |
glam 0.29 | Fast math (Vec2, Mat4) |
bytemuck 1 | Safe GPU data casting |
image 0.25 | PNG texture loading |
noise 0.8 | Perlin noise generation |
web-time 1 | Cross-platform Instant |
gilrs 0.11 | Gamepad input (native only) |
pollster 0.4 | Async blocking (native only) |
Game Crate
| Dependency | Purpose |
|---|---|
journey-engine (path, aliased as engine) | The engine library |
rand 0.8 | Random number generation |
instant 0.1 | WASM-compatible timing |
log 0.4 | Logging facade |
Resonance & Cadence
This document explains how to actually use the procedural audio stack in the Journey Engine ecosystem. The stack consists of Resonance (the DSP physics core) and Cadence (the procedural sequencer logic).
Both systems are no_std, perform zero heap allocations, and are completely agnostic to how you output the audio. They generate and manipulate raw PCM data, which you then pass to a sink like cpal (for native builds) or an AudioWorkletNode (for the web).
1. Using Resonance (DSP Primitive)
Resonance provides raw oscillators, ADSR envelopes, and pre-configured "patches" (like Kick, Snare, Laser). It generates PCM audio frame by frame.
Playing a Pre-Configured Patch
The easiest way to generate sound is to use PatchVoice, which manages its own ADSR state and oscillator math.
#![allow(unused)] fn main() { use resonance::patch::{Patch, PatchVoice}; // 1. Initialize a voice with the target sample rate (e.g., 44,100 Hz) let mut voice = PatchVoice::new(44_100); // 2. Trigger a specific sound voice.trigger(Patch::Kick); // 3. Inside your audio thread, poll the voice for samples let mut buffer = [0i16; 512]; for i in 0..buffer.len() { if voice.is_active() { buffer[i] = voice.next_sample(); } else { buffer[i] = 0; } } // Now pass `buffer` to your audio hardware sink }
Mixing Multiple Voices
Because Resonance is pure math, mixing is just adding integers together and dividing by the number of voices (or clamping/compressing).
#![allow(unused)] fn main() { let mut kick = PatchVoice::new(44_100); let mut snare = PatchVoice::new(44_100); kick.trigger(Patch::Kick); snare.trigger(Patch::Snare); // Mixing in a loop let sample_k = kick.next_sample() as i32; let sample_s = snare.next_sample() as i32; // Mix and clamp back to i16 let mixed = (sample_k + sample_s).clamp(i16::MIN as i32, i16::MAX as i32) as i16; }
2. Using Cadence (Procedural Sequencer)
Cadence is a logic layer. It doesn't output audio; it tells you when to trigger audio based on a BPM and mathematical rulesets.
The Transport Clock
The Transport is the source of truth for time. It converts human-readable BPM into discrete audio samples, ensuring zero timing drift.
#![allow(unused)] fn main() { use cadence::Transport; // 120 BPM at 44,100 Hz let mut transport = Transport::new(44_100, 120.0); // In your audio loop, tick the transport forward one sample if transport.tick() { // This block executes exactly when a 16th-note boundary is crossed let current_step = transport.current_step(); println!("Step {} fired!", current_step); } }
Euclidean Rhythms
Cadence uses Bjorklund's algorithm (EuclideanPattern) to distribute beats evenly. You map these patterns to patches.
#![allow(unused)] fn main() { use cadence::EuclideanPattern; // Distribute 4 beats evenly across 16 steps (a standard 4-on-the-floor kick) // E(pulses, steps, offset) let kick_pattern = EuclideanPattern::<16>::new(4, 16, 0); // Distribute 2 beats across 16 steps, offset by 4 (backbeat snare) let snare_pattern = EuclideanPattern::<16>::new(2, 16, 4); if transport.tick() { let step = transport.current_step() as usize; if kick_pattern.is_active(step) { kick_voice.trigger(Patch::Kick); } if snare_pattern.is_active(step) { snare_voice.trigger(Patch::Snare); } } }
Markov Chains for Melody
For generative melodies, use a MarkovChain to pick the next note from a frequency array based on probability weights.
#![allow(unused)] fn main() { use cadence::MarkovChain; // Pentatonic scale (A3 to D5) let scale = [220.0, 261.63, 293.66, 329.63, 392.00, 440.0, 523.25, 587.33]; // 8x8 Probability matrix. Higher numbers mean higher probability of transition. let matrix = [ [0, 40, 10, 5, 0, 0, 0, 5], // A3 mostly goes to C4 [20, 0, 35, 10, 5, 0, 0, 0], // C4 mostly goes to D4 // ... ]; let mut markov = MarkovChain::new(matrix, 0 /* starting state index */); // To get the next note, pass in a random seed (or use the built-in LFSR) let next_state_index = markov.next(0xBEEF); let frequency_hz = scale[next_state_index]; // Trigger your melodic synthesizer with `frequency_hz` }
3. Putting It All Together
Here is what a complete, standalone procedural audio thread looks like using both primitives:
#![allow(unused)] fn main() { use cadence::{Transport, EuclideanPattern}; use resonance::patch::{Patch, PatchVoice}; pub fn audio_thread_loop(sample_rate: u32, output_buffer: &mut [i16]) { // 1. Initialize State let mut transport = Transport::new(sample_rate, 120.0); let kick_pattern = EuclideanPattern::<16>::new(4, 16, 0); let snare_pattern = EuclideanPattern::<16>::new(2, 16, 4); let mut kick = PatchVoice::new(sample_rate); let mut snare = PatchVoice::new(sample_rate); // 2. Fill the audio buffer for i in 0..output_buffer.len() { // A. Tick Logic if transport.tick() { let step = transport.current_step() as usize; if kick_pattern.is_active(step) { kick.trigger(Patch::Kick); } if snare_pattern.is_active(step) { snare.trigger(Patch::Snare); } } // B. Tick Physics let k_sample = kick.next_sample() as i32; let s_sample = snare.next_sample() as i32; // C. Mix output_buffer[i] = (k_sample + s_sample).clamp(i16::MIN as i32, i16::MAX as i32) as i16; } } }
This code allocates no memory, pulls zero files from disk, and runs indefinitely. Because it operates entirely on arrays and integer math, it will effortlessly compile directly into an AudioWorkletProcessor for WebAssembly or stream directly into cpal for desktop.