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
Procedural AudioGuides and examples for using the custom Resonance (no_std DSP) and Cadence (Sequencer) audio stack

At a Glance

[dependencies]
journey-engine = "1.2.0"
journey-sound = "1.0.0"
journey-sequencer = "1.0.0"
#![allow(unused)]
fn main() {
use engine::{Context, GameAction, GameApp};

struct MyGame;

impl GameApp for MyGame {
    type Action = MyAction;
    fn init(ctx: &mut Context<MyAction>) -> Self { MyGame }
    fn update(&mut self, ctx: &mut Context<MyAction>) {}
    fn fixed_update(&mut self, ctx: &mut Context<MyAction>) {}
    fn render(&mut self, ctx: &mut Context<MyAction>) {}
}
}

See Engine API → Quick Start for the full minimal example.

Journey Engine API Reference

Public API documentation for the journey-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. Atmosphere 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_fpsu32Native display frame cap; WASM follows browser RAF/vsync
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
bloomBloomSettingsPersistent post-process bloom settings

Methods

Texture Loading

#![allow(unused)]
fn main() {
pub fn load_texture(&mut self, bytes: &'static [u8], label: &str) -> usize
}

Queue a texture for loading during init(). Returns a 1-based texture ID (0 is the built-in white pixel). The engine processes these after init completes.

#![allow(unused)]
fn main() {
//? In GameApp::init()
let tex_player = ctx.load_texture(
    include_bytes!("../assets/player/player.png"),
    "Player Spritesheet",
);
//? tex_player == 1 (first loaded texture)
}

Drawing

#![allow(unused)]
fn main() {
//? Draw a colored rectangle
pub fn draw_rect(&mut self, position: Vec2, size: Vec2, color: [f32; 4])
pub fn draw_rect_layer(&mut self, layer: RenderLayer, position: Vec2, size: Vec2, color: [f32; 4])
pub fn draw_rect_additive(&mut self, position: Vec2, size: Vec2, color: [f32; 4])

//? Draw a sprite with optional horizontal flip
pub fn draw_sprite(&mut self, position: Vec2, size: Vec2, color: [f32; 4], flip_x: bool)

//? Draw a region from a sprite sheet
pub fn draw_sprite_from_sheet(
    &mut self,
    position: Vec2,    //? Top-left corner in screen space
    size: Vec2,        //? Width and height in pixels
    color: [f32; 4],   //? RGBA tint (0.0–1.0 each)
    source_rect: Rect, //? Sprite sheet region in pixel coordinates
    flip_x: bool,      //? Horizontal mirror
    texture_id: usize,  //? 1+ for loaded textures, 0 for white pixel
)

//? Same as above with additive blending (for effects/glow)
pub fn draw_sprite_from_sheet_additive(
    &mut self, position: Vec2, size: Vec2, color: [f32; 4],
    source_rect: Rect, flip_x: bool, texture_id: usize,
)
}

Usage from game:

#![allow(unused)]
fn main() {
//? Draw a solid red rectangle (health bar)
ctx.draw_rect(Vec2::new(10.0, 10.0), Vec2::new(100.0, 8.0), [1.0, 0.0, 0.0, 1.0]);

//? Draw the player from a spritesheet
let frame_rect = Rect::new(col as f32 * 100.0, row as f32 * 100.0, 100.0, 100.0);
ctx.draw_sprite_from_sheet(
    player_pos,
    Vec2::new(50.0, 50.0),
    [1.0, 1.0, 1.0, 1.0], //? White tint = original colors
    frame_rect,
    !player.facing_right(),
    1, //? texture ID from load_texture
);

//? Additive glow effect on a hit flash
ctx.draw_sprite_from_sheet_additive(
    position, size, [1.0, 0.8, 0.2, 0.6], source_rect, flip_x, texture_id,
);

//? Additive colored rectangle on the effects layer
ctx.draw_rect_additive(position, size, [0.2, 0.9, 1.0, 0.5]);

//? Debug geometry draws after world/effects sprites
ctx.draw_rect_layer(RenderLayer::Debug, position, size, [0.0, 1.0, 0.0, 0.8]);
}

Effects

#![allow(unused)]
fn main() {
//? Freeze physics and FSM for N render frames (hitstop)
pub fn trigger_freeze(&mut self, frames: u16)

//? Queue a screen shake with intensity and duration in seconds
pub fn trigger_shake(&mut self, intensity: f32, duration: f32)

//? Temporarily override bloom for this frame only
pub fn override_bloom(&mut self, settings: BloomSettings)
}

Usage from game:

#![allow(unused)]
fn main() {
//? Parry hitstop: 3-frame freeze + screen shake
ctx.trigger_freeze(3);
ctx.trigger_shake(4.0, 0.15);
}

Bloom

#![allow(unused)]
fn main() {
pub struct BloomSettings {
    pub enabled: bool,
    pub threshold: f32,
    pub intensity: f32,
    pub radius: f32,
}
}

Bloom is a post-process primitive. It extracts bright pixels from the finished internal-resolution scene, samples a compact neighborhood, and composites the glow during the final blit. Use additive draw calls for neon/effect sources, then enable bloom globally through ctx.bloom or temporarily with ctx.override_bloom(...).

#![allow(unused)]
fn main() {
ctx.bloom.enabled = true;
ctx.bloom.threshold = 0.65;
ctx.bloom.intensity = 0.35;
ctx.bloom.radius = 2.0;

ctx.override_bloom(BloomSettings {
    enabled: true,
    threshold: 0.58,
    intensity: 0.28,
    radius: 2.0,
});
}

Audio

The engine only manages UI-level audio events (UiAudioEvent). Game-specific audio events should be defined and queued by the game crate. The runtime automatically deduplicates pending_ui_audio each frame.

#![allow(unused)]
fn main() {
//? UI audio is handled automatically via the AudioResponse trait on egui widgets.
//? Game-specific audio (jump, dash, etc.) is managed by the game crate's own event queue.
}

Utility

#![allow(unused)]
fn main() {
pub fn screen_center(&self) -> Vec2
pub fn set_fullscreen_enabled(&mut self, enabled: bool)
pub fn set_hdr_enabled(&mut self, enabled: bool)
}

Input

Generic, action-based input system that decouples hardware keys from game intent. Each game defines its own action enum by implementing the GameAction trait.

Module: engine::input

GameAction Trait

The engine's input system is generic over this trait. There are no hardcoded actions, each game defines its own.

#![allow(unused)]
fn main() {
pub trait GameAction: Copy + Eq + Debug + 'static {
    fn count() -> usize;
    fn index(&self) -> usize;
    fn from_index(index: usize) -> Option<Self>;

    //? Optional: wire these up for get_move_x() / get_move_y() axis helpers
    fn move_negative_x() -> Option<Self> { None }
    fn move_positive_x() -> Option<Self> { None }
    fn move_negative_y() -> Option<Self> { None }
    fn move_positive_y() -> Option<Self> { None }
}
}

Defining Actions (Game-Side)

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum JourneyAction {
    MoveLeft, MoveRight, MoveUp, MoveDown,
    Jump, Attack, Block, Dash, Grapple,
}

impl GameAction for JourneyAction {
    fn count() -> usize { 9 }
    fn index(&self) -> usize { *self as usize }
    fn from_index(i: usize) -> Option<Self> {
        match i {
            0 => Some(Self::MoveLeft),  1 => Some(Self::MoveRight),
            2 => Some(Self::MoveUp),    3 => Some(Self::MoveDown),
            4 => Some(Self::Jump),      5 => Some(Self::Attack),
            6 => Some(Self::Block),     7 => Some(Self::Dash),
            8 => Some(Self::Grapple),   _ => None,
        }
    }
    fn move_negative_x() -> Option<Self> { Some(Self::MoveLeft) }
    fn move_positive_x() -> Option<Self> { Some(Self::MoveRight) }
    fn move_negative_y() -> Option<Self> { Some(Self::MoveUp) }
    fn move_positive_y() -> Option<Self> { Some(Self::MoveDown) }
}
}

Key

#![allow(unused)]
fn main() {
pub enum Key {
    W, A, S, D, Space, Shift, Alt,
    Up, Down, Left, Right, F12, Escape,
}
}

MouseBinding

#![allow(unused)]
fn main() {
pub enum MouseBinding {
    Left,
    Right,
    Middle,
}
}

InputState<A> Methods

#![allow(unused)]
fn main() {
//? Is the action currently held down?
pub fn is_action_pressed(&self, action: A) -> bool

//? Was the action pressed this frame (rising edge)?
pub fn is_action_just_pressed(&self, action: A) -> bool

//? Was the action pressed within a recent time window? (for jump buffering)
pub fn was_action_pressed_buffered(&self, action: A, buffer_window: f32) -> bool

//? Raw key queries (for non-action keys like Escape)
pub fn is_key_pressed(&self, key: Key) -> bool
pub fn is_key_just_pressed(&self, key: Key) -> bool

//? Mouse button state
pub fn is_mouse_pressed(&self, button: MouseButton) -> bool

//? Axis values: -1.0 to 1.0 (combines keyboard + gamepad)
//? Requires move_negative_x/move_positive_x on your GameAction impl
pub fn get_move_x(&self) -> f32
pub fn get_move_y(&self) -> f32

//? Device detection
pub fn any_keyboard_or_mouse(&self) -> bool
pub fn any_gamepad(&self) -> bool

//? Access binding configuration
pub fn input_map_mut(&mut self) -> &mut InputMap<A>
}

InputMap<A> (Custom Bindings)

The engine ships with no default bindings. Games register their own during init():

#![allow(unused)]
fn main() {
pub fn bind_key(&mut self, key: Key, action: A)
pub fn bind_mouse(&mut self, button: MouseBinding, action: A)

//? Native only:
pub fn bind_button(&mut self, button: gilrs::Button, action: A)
}

Setting Up Bindings (Game-Side)

#![allow(unused)]
fn main() {
fn init(ctx: &mut Context<JourneyAction>) -> Self {
    let map = ctx.input.input_map_mut();
    map.bind_key(Key::A, JourneyAction::MoveLeft);
    map.bind_key(Key::Left, JourneyAction::MoveLeft);
    map.bind_key(Key::D, JourneyAction::MoveRight);
    map.bind_key(Key::Right, JourneyAction::MoveRight);
    map.bind_key(Key::Space, JourneyAction::Jump);
    map.bind_mouse(MouseBinding::Left, JourneyAction::Attack);
    map.bind_mouse(MouseBinding::Right, JourneyAction::Block);
    //? ...
}
}

Usage from game

#![allow(unused)]
fn main() {
fn fixed_update(&mut self, ctx: &mut Context<JourneyAction>, fixed_time: &FixedTime) {
    let move_x = ctx.input.get_move_x();

    //? Jump with buffering (8 ticks ≈ 133ms at 60Hz)
    let buffer_window = self.stats.jump_buffer_ticks as f32 / 60.0;
    if ctx.input.was_action_pressed_buffered(JourneyAction::Jump, buffer_window) {
        self.execute_jump();
    }

    //? Dash on just-pressed (no buffering)
    if ctx.input.is_action_just_pressed(JourneyAction::Dash) {
        self.start_dash(move_x);
    }
}
}

Journey’s Key Bindings (Game-Side)

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)
}
}

RenderLayer

#![allow(unused)]
fn main() {
pub enum RenderLayer {
    Background,
    World,      //? Default gameplay layer
    Effects,    //? Glows, trails, particles, additive rects
    Debug,      //? Collision boxes and debug overlays
}
}

Layers are a coarse draw-order contract, not a scene system. The renderer draws layers in declaration order, and within each layer draws alpha before additive.

Sprite (Internal)

The Sprite struct is not typically created directly by game code. Instead, use the Context draw methods which build sprites internally:

#![allow(unused)]
fn main() {
//? Solid colored rectangle
ctx.draw_rect(pos, size, color);

//? Explicit layer for debug or background/world split
ctx.draw_rect_layer(RenderLayer::Debug, pos, size, color);

//? Additive colored rectangle for neon/effects
ctx.draw_rect_additive(pos, size, color);

//? Textured sprite from sheet
ctx.draw_sprite_from_sheet(pos, size, color, source_rect, flip_x, texture_id);

//? Additive blended sprite
ctx.draw_sprite_from_sheet_additive(pos, size, color, source_rect, flip_x, texture_id);
}

Rendering Details

  • Up to 65_536 sprites per frame (instanced rendering)
  • Sprites are ordered by RenderLayer, then BlendMode, then batched by texture_id
  • Two render pipelines: alpha blend (default) and additive blend
  • Bloom is applied during the final blit when enabled
  • Horizontal flipping is done in UV-space, avoiding anchor-offset artifacts
  • texture_id = 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);
}

Atmosphere and Scene

Procedural sky and fog rendering.

Module: engine::atmosphere

SkyParams

#![allow(unused)]
fn main() {
pub struct SkyParams {
    pub enabled: bool,
    pub horizon_glow: f32,
    pub top_color: [f32; 3],
    pub horizon_color: [f32; 3],
    pub bottom_color: [f32; 3],
    pub horizon_y: f32,
    pub horizon_width: f32,
}
}

SkyParams controls the gradient sky primitive. The engine defaults this on for Journey's game scene and options UI, replacing the older solid background_color path for normal gameplay. The old background color remains as a fallback for explicit overrides such as benchmark scenes.

SkyParams also exposes a lerp method for transitions:

#![allow(unused)]
fn main() {
impl SkyParams {
    pub fn lerp(&self, other: &SkyParams, t: f32) -> SkyParams;
}
}

SkyTransition

#![allow(unused)]
fn main() {
pub struct SkyTransition {
    pub current: SkyParams,
    pub target: SkyParams,
    pub duration: f32,
    pub elapsed: f32,
}
}

A one-shot transition helper that interpolates from current to target sky over duration seconds.

#![allow(unused)]
fn main() {
let mut t = SkyTransition::new(day_sky, night_sky, 2.0);
t.advance(dt);
let params = t.lerp(); //* returns interpolated SkyParams at current progress
if t.done() { /* transition complete */ }
}

SceneParams

#![allow(unused)]
fn main() {
pub struct SceneParams {
    pub background_color: [f32; 3],  //? Fallback RGB (0.0–1.0)
    pub sky: SkyParams,
    pub seed: u32,
    pub fog_enabled: bool,
    pub fog_density: f32,
    pub fog_opacity: f32,
    pub fog_color: [f32; 3],
    pub fog_anim_speed: f32,
    pub time: f32,
}
}

SceneParams is passed to GameApp::ui() so games can expose debug or options controls. The runtime renders two 32x32 CPU-generated atmosphere textures:

  • Sky texture: gradient/horizon data, sampled linearly by the fullscreen shader so the sky blends smoothly.
  • Fog texture: Perlin fog overlay with alpha coverage, sampled with nearest filtering so fog keeps the chunky retro look.

The fullscreen background shader composites sky.rgb with fog.rgb using fog.a, then world sprites render on top at the game's internal resolution.

Atmosphere Functions

#![allow(unused)]
fn main() {
pub fn hex_to_rgb(hex: &str) -> (u8, u8, u8)
pub fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32
pub fn draw_gradient(buffer, width, height, top_rgb, bot_rgb)
pub fn render_sky_to_buffer(buffer, width, height, params)
pub fn render_fog_overlay(buffer, width, height, time, density, opacity, perlin, seed, fog_rgb, anim_speed)
pub fn render_fog_to_buffer(buffer, width, height, params, fog_noise_cache)
pub fn render_atmosphere_to_buffer(buffer, width, height, params, fog_noise_cache)
}

These are called internally by the engine render loop. render_atmosphere_to_buffer remains as a CPU-composited helper for tests and non-GPU callers.


Re-exports

The engine's lib.rs re-exports commonly used types for ergonomic imports:

#![allow(unused)]
fn main() {
//? Instead of engine::context::Context, just use:
use engine::Context;

//? All re-exports:
pub use audio::{AudioManager, AudioResponse, AudioTrack, UiAudioEvent, load_sound_data};
pub use camera::ScreenShake;
pub use context::Context;
pub use glam::{Vec2, Vec3, Vec4};
pub use input::{GameAction, InputMap, InputState, Key, MouseBinding};
pub use kira::sound::static_sound::StaticSoundData;
pub use math::move_towards;
pub use physics::{AABB, BoxVolume, CollisionLayer, SweepResult};
pub use sprite::{BlendMode, Rect, RenderLayer};
pub use BloomSettings;
pub use SkyParams;
pub use SkyTransition;
pub use SceneParams;
pub use animation::{AnimationDef, AnimationState};
pub use texture::Texture;
pub use texture_manager::TextureHandle;
pub use time::FixedTime;

//? Re-exported dependencies for direct use by game crates:
pub use egui;
#[cfg(not(target_arch = "wasm32"))]
pub use gilrs;
}

Entry Points

#![allow(unused)]
fn main() {
//? Native: blocks on async GPU init
pub fn run<G: GameApp>()

//? WASM: non-blocking entry
pub fn run_wasm<G: GameApp>()
}

Journey Engine Technical Documentation

Version: 1.1.2 · Edition: Rust 2024 · License: MIT Repository: github.com/ujjwalvivek/journey


Table of Contents

  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 · Atmosphere│
├─────────────────────────────────────────────────────────┤
│              Platform Abstraction Layer                 │
│  wgpu (GPU) · winit (Windowing) · kira (Audio)          │
│  gilrs (Gamepad, native-only) · egui (Debug UI)         │
└─────────────────────────────────────────────────────────┘

Design philosophy: Data-oriented, trait-driven, and deliberately narrow. Games implement the GameApp trait to hook into the engine's lifecycle. The engine calls init → fixed_update → update → render → ui each frame. All mutable engine access flows through a single Context struct.


Workspace Layout

Journey/
├── Cargo.toml              # Workspace root (members: engine, game)
├── engine/                  # Reusable library - the product
│   ├── Cargo.toml           # wgpu, winit, kira, egui, glam, bytemuck
│   ├── assets/shaders/      # WGSL shaders (sprite, atmosphere)
│   └── src/
│       ├── lib.rs           # Public API surface, GameApp trait, re-exports
│       ├── runtime.rs       # Event loop, wGPU init, render orchestration
│       ├── context.rs       # Context struct passed to GameApp methods
│       ├── input.rs         # Action-based input (keyboard, mouse, gamepad)
│       ├── physics.rs       # AABB, swept collision, collision layers
│       ├── sprite.rs        # Instanced sprite renderer, blend modes
│       ├── texture.rs       # GPU texture loading
│       ├── texture_manager.rs # Texture asset manager with handles
│       ├── camera.rs        # Orthographic camera, screen shake
│       ├── audio.rs         # Kira wrapper, sub-tracks, events
│       ├── animation.rs     # Asset-agnostic animation state machine
│       ├── time.rs          # Fixed-timestep accumulator
│       ├── math.rs          # Shared math helpers (move_towards)
│       └── atmosphere.rs    # Sky gradient and Perlin fog atmosphere buffers
├── game/                    # Executable - the content
│   ├── Cargo.toml           # Depends on journey-engine (aliased as engine)
│   ├── assets/              # Sprites, audio, levels
│   ├── pkg/                 # WASM build artifacts (wasm-pack output)
│   └── src/
│       ├── main.rs          # Native entry point
│       ├── lib.rs           # JourneyGame: GameApp implementation, game states
│       ├── input.rs         # JourneyAction enum and key/gamepad bindings
│       ├── player.rs        # Player controller FSM, input handling
│       ├── enemy.rs         # Enemy AI (Grunt, Sniper, Ronin)
│       ├── entity.rs        # Shared Entity struct (physics + combat)
│       ├── combat/          # Frame-data FSM, input buffer, move database
│       ├── projectile.rs    # Projectile pool and collision
│       ├── level.rs         # ASCII-based level generation
│       ├── level_editor.rs  # In-game level editor (F12)
│       ├── anim.rs          # Spritesheet animation definitions
│       ├── assets.rs        # Player animation grid mappings
│       ├── audio.rs         # Audio asset loading and dispatch
│       ├── config.rs        # Physics constants and tuning
│       ├── scene.rs         # egui debug overlay
│       └── start_sequence.rs # Splash, menus, options UI
└── web/                     # Vite project for WASM distribution
    ├── src/                 # JavaScript glue code
    ├── public/              # Static assets (favicon)
    └── vite.config.ts       # WASM bundling config

Build Targets

Native (Desktop)

cargo run --bin journey              # Debug
cargo run --bin journey --release    # Release

Uses pollster to block on async wGPU initialization. Gamepad support via gilrs. Window title and icon are configured by the game's GameApp implementation (Journey loads its icon from web/public/favicon.png via GameApp::window_icon()).

WebAssembly (Browser)

wasm-pack build game --target web    # Build WASM artifacts to game/pkg/
cd web && npm install && npm run dev  # Start Vite dev server

Uses wasm-bindgen-futures for async GPU init. console_error_panic_hook pipes Rust panics to browser DevTools. Audio requires a user gesture to unlock the Web Audio API context.

Internal Resolution

All gameplay renders to a configurable internal-resolution offscreen buffer (default 640×360, set via GameApp::internal_resolution()), upscaled with nearest-neighbor filtering for a retro pixel aesthetic. The CPU atmosphere pass operates at a fixed 32×32 simulation resolution.


Core Engine Systems

The engine exposes functionality through a small set of public modules, all re-exported from engine::lib.rs for ergonomic imports:

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
atmosphereSky gradient and Perlin fog atmosphere rendering

Game Layer

The game crate implements GameApp for JourneyGame and owns all content:

Game State Machine

#![allow(unused)]
fn main() {
enum GameState {
    Splash { timer: f32 },
    StartMenu { animation_progress: f32 },
    Options { return_state, tab },
    LevelEditor { return_state },
    Benchmark,
    InGame,
    Paused,
}
}

State transitions drive music changes, input routing, and UI visibility. The splash screen is skipped on WASM builds.

Shared Entity Model

Journey uses direct composition. Actors such as the player and enemies share an Entity struct containing:

  • Position and velocity (Vec2)
  • Facing direction
  • Pushbox and hurtbox sizes (AABB)
  • Combat state (CombatState)
  • Health

System functions like fixed_update_physics() and integrate_and_collide() operate on &mut Entity generically. This keeps the hot gameplay path explicit and simple while still avoiding duplicated movement, collision, and combat state across actor types.

Player Controller

The player is a state-machine-driven controller with states: Idle, Run, Jump, Fall, Dash, AirDash, Parry, AttackHorizontal, AttackUp, AttackDown, WallGrab, WallSlide, GrapplePull, GrappleSlingshot, Death. Each state handles its own movement, transitions, coyote timing, jump buffering, and animation selection.

Combat input is sampled every render frame but consumed in fixed_update() via a tick-stamped CombatInputBuffer for deterministic FSM synchronization.

Enemy System

Three enemy types (Grunt, Sniper, Ronin) share an FSM but differ via a data-driven EnemyConfig table. Core mechanic chain: Enemy shoots → Player parries → Enemy staggers → Player grapples → Execute.


Game Loop and Timing Model

The engine uses a fixed-timestep with interpolation model:

Each Frame:
  1. Measure wall-clock delta time (capped at 100ms)
  2. Feed delta into FixedTime accumulator
  3. Run N fixed_update() steps at fixed_dt (default 1/60s)
     - Cap at MAX_STEPS=5 to prevent spiral of death
  4. Compute interpolation_alpha for render smoothing
  5. Call update() with variable delta_time
  6. Call render() for sprite submission
  7. Call ui() for egui overlay
  8. GPU: upload sprites, draw background + sprites + egui

FixedTime

  • tick: Monotonic 64-bit counter, incremented once per fixed step.
  • fixed_dt: Duration of one fixed step (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 Atmosphere Pass (32×32)           │
│   Sky gradient texture + fog overlay texture│
├─────────────────────────────────────────────┤
│        Full-Screen Quad (background)        │
│   Linear sky sample + nearest fog sample    │
├─────────────────────────────────────────────┤
│     Instanced Sprite Pass (640×360)         │
│   RenderLayer → Alpha → Additive            │
│   Batched by texture_id, for 65,536 sprites │
├─────────────────────────────────────────────┤
│       Bloom Composite / Final Blit          │
│   Bright-pass neighborhood glow + upscale   │
├─────────────────────────────────────────────┤
│           egui Overlay Pass                 │
│   Debug UI, menus, HUD                      │
└─────────────────────────────────────────────┘

Atmosphere Rendering

The background is not a scene graph or entity system. It is a small CPU-generated atmosphere pass built from SceneParams:

  • SkyParams produces the base sky gradient with top, horizon, and bottom colors.
  • Fog is generated into a separate Perlin overlay texture where RGB stores fog color and alpha stores coverage.
  • Both textures stay at 32×32.
  • The fullscreen shader samples the sky texture with linear filtering, then samples the fog texture with nearest filtering and composites mix(sky.rgb, fog.rgb, fog.a).

This split keeps the sky blended while preserving the blocky legacy fog style. Gameplay sprites still render later into the internal 640×360 scene and remain nearest-upscaled.

Sprite Rendering

Sprites are submitted via ctx.draw_sprite(), ctx.draw_rect(), and ctx.draw_sprite_from_sheet() during render(). They are collected into a Vec<Sprite>, partitioned by coarse RenderLayer, then by BlendMode, converted to SpriteInstance GPU data, batched by texture ID, and rendered with instanced draw calls.

The sprite layer contract is intentionally small:

  • Background: optional sprite-backed background elements above the atmosphere pass
  • World: default gameplay sprites and solid geometry
  • Effects: glows, trails, particles, and additive rectangles
  • Debug: collision boxes and diagnostic overlays

This is a render-order contract only. It is not a scene graph, ownership model, or ECS substitute.

Two render pipelines exist within the same render pass:

  • Alpha blend (default): Standard transparency
  • Additive blend: For effects like hit flashes, trails, projectile glow, and neon rectangles

Additive blending brightens existing pixels. Bloom is the separate post-process that makes those bright pixels bleed into neighboring pixels. The engine exposes this through BloomSettings, either persistently through ctx.bloom or for a single frame with ctx.override_bloom(...).

The current bloom pass is intentionally small: it runs during the final blit from the internal render target to the window surface, keeps the source image crisp with textureLoad, extracts bright pixels above a threshold, samples a compact neighborhood, and composites the glow back over the scene. It is designed for neon/pixel-art styling, not physically accurate lighting.

Horizontal sprite flipping is done in UV-space (not scale-space) to eliminate anchor-offset artifacts.

Camera

An orthographic camera maps pixel coordinates to NDC space. It supports:

  • Horizontal and vertical panning (camera follow)
  • Additive screen shakes with exponential decay and Lissajous-like orbits
  • Resize handling for window/canvas changes

Physics and Collision

AABB (Axis-Aligned Bounding Box)

The primary collision primitive. Constructed from center + size or top-left + size:

#![allow(unused)]
fn main() {
let box_a = AABB::new(center, size);
let box_b = AABB::from_top_left(top_left, size);
}

Operations:

  • check_collision(): Boolean overlap 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 pipeline supports two distinct paradigms: Static Audio (sample playback) and Procedural Audio (generative synthesis).

Static Audio (Kira)

For traditional .wav/.ogg playback, the engine wraps kira with lazy initialization (required on WASM for Web Audio API gesture requirements):

AudioManager
├── Music track     (looping, crossfade)
├── Ambience track  (looping, crossfade)
├── SFX track       (one-shot + looping)
└── UI track        (one-shot)

Each track has independent volume control. Music and ambience use handle tracking to prevent overlapping loops. All sounds are embedded via include_bytes!() and decoded at init time for cross-platform compatibility.

Procedural Audio (Resonance & Cadence)

To support dynamic, endless, and generative audio without inflating binary size with megabytes of static files, the ecosystem includes a custom, zero-allocation procedural audio stack built as independent crates:

  1. Resonance (no_std DSP Primitive): A pure-math audio synthesis primitive. It generates waveforms (Sine, Square, Triangle, Sawtooth) via compile-time generated LUTs (via build.rs), eliminating the need for std::f32::sin() and enabling bare-metal usage. It features a programmable ADSR envelope system and handles PCM buffer filling entirely using fixed-point u32 phase accumulators.
  2. Cadence (no_std Sequencer): The mathematical logic core sitting above Resonance. It tracks time accurately via discrete audio sample counting (guaranteeing zero drift indefinitely) and executes deterministic algorithms like Euclidean Rhythms (Bjorklund's algorithm) and Markov Chains (via an LFSR) to trigger real-time events.

Decoupled Sinks: Both Resonance and Cadence are pure state machines completely unaware of their audio sink. In native builds, they can be fed to a cpal audio driver. On the web, they compile to a tiny wasm32-unknown-unknown blob running directly inside an AudioWorkletNode, remaining perfectly phase-locked and executing completely off the main thread.

Event-Driven SFX

The engine provides UiAudioEvent for UI sounds (hover, click, checkbox, tab change), managed via ctx.pending_ui_audio and deduplicated per frame. Game-specific audio events (jump, dash, hit, etc.) are defined and queued entirely by the game crate, keeping the engine free of game-specific coupling.

#![allow(unused)]
fn main() {
//? Engine UI events (automatic via AudioResponse trait on egui widgets)
ui.button("Attack").with_ui_sound(&mut ctx.pending_ui_audio);

//? Game events (game-defined enum, game-managed queue)
self.pending_game_audio.push(AudioEvent::Jump);
}

Animation System

Two-Layer Design

  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
Visual FPS capNative sleep/WaitUntil capBrowser RAF/vsync
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 cap65,536 instanced sprites per frame
Physics stepsMax 5 per frame (spiral-of-death cap)
Atmosphere resolution32×32 CPU sky/fog 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

Resonance & Cadence

This document explains how to actually use the procedural audio stack in the Journey Engine ecosystem. The stack consists of Resonance (the DSP physics core) and Cadence (the procedural sequencer logic).

Both systems are no_std, perform zero heap allocations, and are completely agnostic to how you output the audio. They generate and manipulate raw PCM data, which you then pass to a sink like cpal (for native builds) or an AudioWorkletNode (for the web).


1. Using Resonance (DSP Primitive)

Resonance provides raw oscillators, ADSR envelopes, and pre-configured "patches" (like Kick, Snare, Laser). It generates PCM audio frame by frame.

Playing a Pre-Configured Patch

The easiest way to generate sound is to use PatchVoice, which manages its own ADSR state and oscillator math.

#![allow(unused)]
fn main() {
use resonance::patch::{Patch, PatchVoice};

// 1. Initialize a voice with the target sample rate (e.g., 44,100 Hz)
let mut voice = PatchVoice::new(44_100);

// 2. Trigger a specific sound
voice.trigger(Patch::Kick);

// 3. Inside your audio thread, poll the voice for samples
let mut buffer = [0i16; 512];
for i in 0..buffer.len() {
    if voice.is_active() {
        buffer[i] = voice.next_sample();
    } else {
        buffer[i] = 0;
    }
}
// Now pass `buffer` to your audio hardware sink
}

Mixing Multiple Voices

Because Resonance is pure math, mixing is just adding integers together and dividing by the number of voices (or clamping/compressing).

#![allow(unused)]
fn main() {
let mut kick = PatchVoice::new(44_100);
let mut snare = PatchVoice::new(44_100);

kick.trigger(Patch::Kick);
snare.trigger(Patch::Snare);

// Mixing in a loop
let sample_k = kick.next_sample() as i32;
let sample_s = snare.next_sample() as i32;

// Mix and clamp back to i16
let mixed = (sample_k + sample_s).clamp(i16::MIN as i32, i16::MAX as i32) as i16;
}

2. Using Cadence (Procedural Sequencer)

Cadence is a logic layer. It doesn't output audio; it tells you when to trigger audio based on a BPM and mathematical rulesets.

The Transport Clock

The Transport is the source of truth for time. It converts human-readable BPM into discrete audio samples, ensuring zero timing drift.

#![allow(unused)]
fn main() {
use cadence::Transport;

// 120 BPM at 44,100 Hz
let mut transport = Transport::new(44_100, 120.0);

// In your audio loop, tick the transport forward one sample
if transport.tick() {
    // This block executes exactly when a 16th-note boundary is crossed
    let current_step = transport.current_step();
    println!("Step {} fired!", current_step);
}
}

Euclidean Rhythms

Cadence uses Bjorklund's algorithm (EuclideanPattern) to distribute beats evenly. You map these patterns to patches.

#![allow(unused)]
fn main() {
use cadence::EuclideanPattern;

// Distribute 4 beats evenly across 16 steps (a standard 4-on-the-floor kick)
// E(pulses, steps, offset)
let kick_pattern = EuclideanPattern::<16>::new(4, 16, 0);

// Distribute 2 beats across 16 steps, offset by 4 (backbeat snare)
let snare_pattern = EuclideanPattern::<16>::new(2, 16, 4);

if transport.tick() {
    let step = transport.current_step() as usize;

    if kick_pattern.is_active(step) {
        kick_voice.trigger(Patch::Kick);
    }
    
    if snare_pattern.is_active(step) {
        snare_voice.trigger(Patch::Snare);
    }
}
}

Markov Chains for Melody

For generative melodies, use a MarkovChain to pick the next note from a frequency array based on probability weights.

#![allow(unused)]
fn main() {
use cadence::MarkovChain;

// Pentatonic scale (A3 to D5)
let scale = [220.0, 261.63, 293.66, 329.63, 392.00, 440.0, 523.25, 587.33];

// 8x8 Probability matrix. Higher numbers mean higher probability of transition.
let matrix = [
    [0, 40, 10,  5,  0,  0,  0,  5], // A3 mostly goes to C4
    [20, 0, 35, 10,  5,  0,  0,  0], // C4 mostly goes to D4
    // ...
];

let mut markov = MarkovChain::new(matrix, 0 /* starting state index */);

// To get the next note, pass in a random seed (or use the built-in LFSR)
let next_state_index = markov.next(0xBEEF);
let frequency_hz = scale[next_state_index];

// Trigger your melodic synthesizer with `frequency_hz`
}

3. Putting It All Together

Here is what a complete, standalone procedural audio thread looks like using both primitives:

#![allow(unused)]
fn main() {
use cadence::{Transport, EuclideanPattern};
use resonance::patch::{Patch, PatchVoice};

pub fn audio_thread_loop(sample_rate: u32, output_buffer: &mut [i16]) {
    // 1. Initialize State
    let mut transport = Transport::new(sample_rate, 120.0);
    
    let kick_pattern = EuclideanPattern::<16>::new(4, 16, 0);
    let snare_pattern = EuclideanPattern::<16>::new(2, 16, 4);
    
    let mut kick = PatchVoice::new(sample_rate);
    let mut snare = PatchVoice::new(sample_rate);

    // 2. Fill the audio buffer
    for i in 0..output_buffer.len() {
        
        // A. Tick Logic
        if transport.tick() {
            let step = transport.current_step() as usize;
            
            if kick_pattern.is_active(step) {
                kick.trigger(Patch::Kick);
            }
            if snare_pattern.is_active(step) {
                snare.trigger(Patch::Snare);
            }
        }
        
        // B. Tick Physics
        let k_sample = kick.next_sample() as i32;
        let s_sample = snare.next_sample() as i32;
        
        // C. Mix
        output_buffer[i] = (k_sample + s_sample).clamp(i16::MIN as i32, i16::MAX as i32) as i16;
    }
}
}

This code allocates no memory, pulls zero files from disk, and runs indefinitely. Because it operates entirely on arrays and integer math, it will effortlessly compile directly into an AudioWorkletProcessor for WebAssembly or stream directly into cpal for desktop.