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 |
Quick Links
- Play → journey.ujjwalvivek.com
- Crate → crates.io/crates/journey-engine
- Source → github.com/ujjwalvivek/journey
At a Glance
[dependencies]
journey-engine = "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
- Noise 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 | Target display frame rate |
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 |
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]) //? 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, ); }
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) }
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); }
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) } }
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); //? 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 1024 sprites per frame (instanced rendering)
- Sprites are batched by
texture_idfor minimal GPU state changes - Two render pipelines: alpha blend (default) and additive blend
- 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); }
Noise and Scene
Procedural background rendering.
Module: engine::noise
SceneParams
#![allow(unused)] fn main() { pub struct SceneParams { pub background_color: [f32; 3], //? RGB (0.0–1.0) 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 the ui() method and controls the CPU noise background pass. The engine renders a Perlin fog layer on top of a gradient background, animated over time.
Noise 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 apply_fog(buffer, width, height, time, density, opacity, perlin, seed, fog_rgb, anim_speed) pub fn render_scene_to_buffer(buffer, width, height, params, perlin_cache) }
These are called internally by the engine render loop. Game code typically only modifies SceneParams through the ui() callback.
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}; 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 · Noise │
├─────────────────────────────────────────────────────────┤
│ Platform Abstraction Layer │
│ wgpu (GPU) · winit (Windowing) · kira (Audio) │
│ gilrs (Gamepad, native-only) · egui (Debug UI) │
└─────────────────────────────────────────────────────────┘
Design philosophy: Data-oriented, trait-driven. 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 game state 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, noise)
│ └── 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)
│ └── noise.rs # Perlin fog, gradient background
├── 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 noise pass operates at a tiny 32×32 simulation resolution for performance.
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 |
noise | Perlin noise fog and gradient background 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 }, InGame, Paused, } }
State transitions drive music changes, input routing, and UI visibility. The splash screen is skipped on WASM builds.
Entity-Component Pattern
All actors (player, 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.
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 Noise Pass (32×32) │
│ Perlin fog → gradient → upload to GPU │
├─────────────────────────────────────────────┤
│ Full-Screen Quad (background) │
│ Noise texture stretched to viewport │
├─────────────────────────────────────────────┤
│ Instanced Sprite Pass (640×360) │
│ Alpha pipeline → Additive pipeline │
│ Batched by texture_id, up to 1024 sprites │
├─────────────────────────────────────────────┤
│ egui Overlay Pass │
│ Debug UI, menus, HUD │
└─────────────────────────────────────────────┘
Sprite Rendering
Sprites are submitted via ctx.draw_sprite() and ctx.draw_sprite_from_sheet() during render(). They are collected into a Vec<Sprite>, sorted by texture ID, converted to SpriteInstance GPU data, and rendered with instanced draw calls.
Two render pipelines exist within the same render pass:
- Alpha blend (default): Standard transparency
- Additive blend: For effects like hit flashes and glow
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 system 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.
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); }
Sound Loading
All sounds are embedded via include_bytes!() and decoded with load_sound_data() at init time for cross-platform compatibility.
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 |
| 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 | 1024 instanced sprites per frame |
| Physics steps | Max 5 per frame (spiral-of-death cap) |
| Noise resolution | 32×32 CPU 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 |