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