Journey Engine

A custom 2D game engine built with Rust and wGPU, designed for tight, expressive platformers. Powers Journey.

What's Here

SectionWhat you'll find
Engine APIPublic API reference with usage examples: GameApp, Context, input, physics, sprites, audio, animation
Technical DocumentationArchitecture internals, game loop model, rendering pipeline, cross-platform strategy, dependency map

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-engine crate with usage examples from game.

Version: 1.1.2 · Crate type: rlib


Table of Contents

  1. Quick Start
  2. GameApp Trait
  3. Context
  4. Input
  5. Physics
  6. Sprite and Rendering
  7. Texture Management
  8. Camera and Screen Shake
  9. Audio
  10. Animation
  11. Time
  12. Math Utilities
  13. Noise and Scene
  14. 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

FieldTypeDescription
inputInputState<A>Current keyboard, mouse, and gamepad state (generic over your action enum)
delta_timef32Frame delta (variable in update, fixed in fixed_update)
screen_widthf32Current viewport width in pixels
screen_heightf32Current viewport height in pixels
camera_offset_xf32Horizontal camera pan offset
camera_offset_yf32Vertical camera pan offset
fpsf32Current frames per second
frame_time_msf32Last frame time in milliseconds
fixed_tick_rateu32Fixed update rate in Hz (default: 60)
target_fpsu32Target display frame rate
interpolation_alphaf320.0–1.0 fraction for render-time smoothing
freeze_framesu16Remaining freeze frames (hitstop)
pending_shakesVec<(f32, f32)>Queued screen shakes: (intensity, duration)
request_exitboolSet to true to close the window
fullscreen_enabledboolWhether fullscreen is currently enabled
hdr_enabledboolWhether HDR output is active
audioAudioManagerDirect access to the audio system
pending_ui_audioVec<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)

KeyActionKeyAction
A / ←MoveLeftD / →MoveRight
W / ↑MoveUpS / ↓MoveDown
SpaceJumpShiftDash
AltGrappleMouse LAttack
Mouse RBlock

Journey’s Gamepad Bindings (Game-Side, Native)

ButtonAction
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_id for 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 = 0 uses 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

  1. Overview
  2. Architecture
  3. Workspace Layout
  4. Build Targets
  5. Core Engine Systems
  6. Game Layer
  7. Game Loop and Timing Model
  8. Rendering Pipeline
  9. Physics and Collision
  10. Input System
  11. Audio System
  12. Animation System
  13. Combat System
  14. Level System
  15. Configuration and Tuning
  16. Cross-Platform Strategy
  17. Performance Budget
  18. 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:

ModulePurpose
contextContext struct - the single handle games use to interact with the engine
inputAction-based input mapping (keyboard, mouse, gamepad)
physicsAABB collision detection, swept CCD, collision layers
spriteInstanced GPU sprite renderer with sheet and blend support
texture / texture_managerGPU texture loading and handle-based management
cameraOrthographic camera with screen shake
audioCross-platform audio (Kira) with sub-tracks and events
animationGeneric animation state machine (asset-agnostic)
timeFixed-timestep accumulator with freeze support
mathShared math utilities
noisePerlin 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 (default 1/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 test
  • get_overlap(): Overlap amount per axis
  • resolve_collision(): Minimum translation vector (MTV) to separate two boxes
  • swept_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

  1. Engine layer (engine::animation): Asset-agnostic AnimationDef + AnimationState runtime. Handles frame timing, playback, looping, and state transitions. Does not know about textures.

  2. Game layer (game::anim): Animation struct that wraps AnimationDef with spritesheet-specific logic (grid-based get_frame_rect()). AnimationState wraps 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:

RowFramesAnimation
00–3Idle
15–8Run
210–14Jump (10–12 ascend, 13–14 fall)
315–18Death
420–23Parry
525–28Attack Horizontal
630–33Attack Up
735–38Attack Down
840–43Dash
945–47Wall Grab/Slide
1050–51Grapple

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:

CharElement
#Solid platform
=One-way platform
|Wall
@Player spawn
GGrunt enemy
SSniper enemy
RRonin enemy
OGrapple node
XExit

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.txt on native, localStorage on WASM

Configuration and Tuning

All physics constants are centralized in game/src/config.rs using a PIXELS_PER_UNIT base (16px) for dimensional consistency:

CategoryKey Constants
MovementMOVEMENT_SPEED (300px/s), MAX_SPEED (300px/s)
JumpJUMP_POWER (600px/s), COYOTE_TICKS (6), JUMP_BUFFER_TICKS (8)
GravityGRAVITY (1760px/s²), MAX_FALL_SPEED (640px/s)
DashDASH_SPEED (800px/s), DASH_DURATION_TICKS (8), DASH_COOLDOWN_TICKS (10)
WallWALL_SLIDE_SPEED (120px/s), WALL_GRAB_TIMEOUT_TICKS (10)
GrappleGRAPPLE_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:

ConcernNativeWASM
GPU initpollster::block_on (sync)wasm_bindgen_futures::spawn_local (async)
GamepadgilrsNot available
Audio initImmediate at startupDeferred until user gesture
Loggingenv_loggerconsole_log + console_error_panic_hook
Fullscreenwinit::FullscreenCSS 100vw × 100vh
Level persistenceFile I/O (world.txt)web_sys::Storage (localStorage)
Splash screen2s timerSkipped
ClipboardOS clipboard via arboardNot available

WASM Dependencies

  • wasm-bindgen / wasm-bindgen-futures: JS interop and async support
  • web-sys: DOM manipulation (canvas, storage, window)
  • console_error_panic_hook: Panic messages in DevTools
  • console_log: Rust log facade to browser console
  • getrandom with js feature: Crypto RNG for rand

Performance Budget

MetricTarget
Frame rate60 FPS stable (native + browser)
WASM binary< 5 MB (gzip)
Sprite cap1024 instanced sprites per frame
Physics stepsMax 5 per frame (spiral-of-death cap)
Noise resolution32×32 CPU pass at ~60Hz
Internal resolutionDefault 640×360 (configurable via GameApp), nearest-neighbor upscale

Dependency Map

Engine Crate

DependencyPurpose
wgpu 27Cross-platform GPU abstraction
winit 0.30Windowing and event loop
egui 0.33 / egui-wgpu / egui-winitImmediate-mode debug UI
kira 0.12Cross-platform audio engine
glam 0.29Fast math (Vec2, Mat4)
bytemuck 1Safe GPU data casting
image 0.25PNG texture loading
noise 0.8Perlin noise generation
web-time 1Cross-platform Instant
gilrs 0.11Gamepad input (native only)
pollster 0.4Async blocking (native only)

Game Crate

DependencyPurpose
journey-engine (path, aliased as engine)The engine library
rand 0.8Random number generation
instant 0.1WASM-compatible timing
log 0.4Logging facade