Journey Engine Technical Documentation
Version: 1.1.2 · Edition: Rust 2024 · License: MIT Repository: github.com/ujjwalvivek/journey
Table of Contents
- Overview
- Architecture
- Workspace Layout
- Build Targets
- Core Engine Systems
- Game Layer
- Game Loop and Timing Model
- Rendering Pipeline
- Physics and Collision
- Input System
- Audio System
- Animation System
- Combat System
- Level System
- Configuration and Tuning
- Cross-Platform Strategy
- Performance Budget
- Dependency Map
Overview
Journey Engine is a custom high-performance 2D game engine written in Rust and wGPU. It targets both native desktop (Vulkan/Metal/DX12) and WebAssembly (WebGL/WebGPU) from a single codebase. The engine is purpose-built for a fast-momentum Metroidvania tech demo that prioritizes tight, deterministic, arcade-style physics over realistic simulation.
The project ships as a Cargo workspace with two crates, a reusable journey-engine library (published as journey-engine on crates.io) and a game binary, plus a Vite-based web project for WASM distribution. This separation enforces a clean API boundary: the engine owns rendering, input infrastructure, audio, physics primitives, and the game loop; the game owns content, gameplay logic, level design, input action definitions, and asset definitions.
Architecture
┌─────────────────────────────────────────────────────────┐
│ game (binary crate) │
│ Player · Enemies · Combat FSM · Levels · Assets · UI │
├─────────────────────────────────────────────────────────┤
│ engine (library crate) │
│ GameApp trait · Context · Renderer · Input · Physics │
│ Audio · Animation · Camera · Texture · Time · Noise │
├─────────────────────────────────────────────────────────┤
│ Platform Abstraction Layer │
│ wgpu (GPU) · winit (Windowing) · kira (Audio) │
│ gilrs (Gamepad, native-only) · egui (Debug UI) │
└─────────────────────────────────────────────────────────┘
Design philosophy: Data-oriented, trait-driven. Games implement the GameApp trait to hook into the engine's lifecycle. The engine calls init → fixed_update → update → render → ui each frame. All mutable game state flows through a single Context struct.
Workspace Layout
Journey/
├── Cargo.toml # Workspace root (members: engine, game)
├── engine/ # Reusable library - the product
│ ├── Cargo.toml # wgpu, winit, kira, egui, glam, bytemuck
│ ├── assets/shaders/ # WGSL shaders (sprite, noise)
│ └── src/
│ ├── lib.rs # Public API surface, GameApp trait, re-exports
│ ├── runtime.rs # Event loop, wGPU init, render orchestration
│ ├── context.rs # Context struct passed to GameApp methods
│ ├── input.rs # Action-based input (keyboard, mouse, gamepad)
│ ├── physics.rs # AABB, swept collision, collision layers
│ ├── sprite.rs # Instanced sprite renderer, blend modes
│ ├── texture.rs # GPU texture loading
│ ├── texture_manager.rs # Texture asset manager with handles
│ ├── camera.rs # Orthographic camera, screen shake
│ ├── audio.rs # Kira wrapper, sub-tracks, events
│ ├── animation.rs # Asset-agnostic animation state machine
│ ├── time.rs # Fixed-timestep accumulator
│ ├── math.rs # Shared math helpers (move_towards)
│ └── noise.rs # Perlin fog, gradient background
├── game/ # Executable - the content
│ ├── Cargo.toml # Depends on journey-engine (aliased as engine)
│ ├── assets/ # Sprites, audio, levels
│ ├── pkg/ # WASM build artifacts (wasm-pack output)
│ └── src/
│ ├── main.rs # Native entry point
│ ├── lib.rs # JourneyGame: GameApp implementation, game states
│ ├── input.rs # JourneyAction enum and key/gamepad bindings
│ ├── player.rs # Player controller FSM, input handling
│ ├── enemy.rs # Enemy AI (Grunt, Sniper, Ronin)
│ ├── entity.rs # Shared Entity struct (physics + combat)
│ ├── combat/ # Frame-data FSM, input buffer, move database
│ ├── projectile.rs # Projectile pool and collision
│ ├── level.rs # ASCII-based level generation
│ ├── level_editor.rs # In-game level editor (F12)
│ ├── anim.rs # Spritesheet animation definitions
│ ├── assets.rs # Player animation grid mappings
│ ├── audio.rs # Audio asset loading and dispatch
│ ├── config.rs # Physics constants and tuning
│ ├── scene.rs # egui debug overlay
│ └── start_sequence.rs # Splash, menus, options UI
└── web/ # Vite project for WASM distribution
├── src/ # JavaScript glue code
├── public/ # Static assets (favicon)
└── vite.config.ts # WASM bundling config
Build Targets
Native (Desktop)
cargo run --bin journey # Debug
cargo run --bin journey --release # Release
Uses pollster to block on async wGPU initialization. Gamepad support via gilrs. Window title and icon are configured by the game's GameApp implementation (Journey loads its icon from web/public/favicon.png via GameApp::window_icon()).
WebAssembly (Browser)
wasm-pack build game --target web # Build WASM artifacts to game/pkg/
cd web && npm install && npm run dev # Start Vite dev server
Uses wasm-bindgen-futures for async GPU init. console_error_panic_hook pipes Rust panics to browser DevTools. Audio requires a user gesture to unlock the Web Audio API context.
Internal Resolution
All gameplay renders to a configurable internal-resolution offscreen buffer (default 640×360, set via GameApp::internal_resolution()), upscaled with nearest-neighbor filtering for a retro pixel aesthetic. The CPU noise pass operates at a tiny 32×32 simulation resolution for performance.
Core Engine Systems
The engine exposes functionality through a small set of public modules, all re-exported from engine::lib.rs for ergonomic imports:
| Module | Purpose |
|---|---|
context | Context struct - the single handle games use to interact with the engine |
input | Action-based input mapping (keyboard, mouse, gamepad) |
physics | AABB collision detection, swept CCD, collision layers |
sprite | Instanced GPU sprite renderer with sheet and blend support |
texture / texture_manager | GPU texture loading and handle-based management |
camera | Orthographic camera with screen shake |
audio | Cross-platform audio (Kira) with sub-tracks and events |
animation | Generic animation state machine (asset-agnostic) |
time | Fixed-timestep accumulator with freeze support |
math | Shared math utilities |
noise | Perlin noise fog and gradient background rendering |
Game Layer
The game crate implements GameApp for JourneyGame and owns all content:
Game State Machine
#![allow(unused)] fn main() { enum GameState { Splash { timer: f32 }, StartMenu { animation_progress: f32 }, Options { return_state, tab }, LevelEditor { return_state }, InGame, Paused, } }
State transitions drive music changes, input routing, and UI visibility. The splash screen is skipped on WASM builds.
Entity-Component Pattern
All actors (player, enemies) share an Entity struct containing:
- Position and velocity (
Vec2) - Facing direction
- Pushbox and hurtbox sizes (
AABB) - Combat state (
CombatState) - Health
System functions like fixed_update_physics() and integrate_and_collide() operate on &mut Entity generically.
Player Controller
The player is a state-machine-driven controller with states: Idle, Run, Jump, Fall, Dash, AirDash, Parry, AttackHorizontal, AttackUp, AttackDown, WallGrab, WallSlide, GrapplePull, GrappleSlingshot, Death. Each state handles its own movement, transitions, coyote timing, jump buffering, and animation selection.
Combat input is sampled every render frame but consumed in fixed_update() via a tick-stamped CombatInputBuffer for deterministic FSM synchronization.
Enemy System
Three enemy types (Grunt, Sniper, Ronin) share an FSM but differ via a data-driven EnemyConfig table. Core mechanic chain: Enemy shoots → Player parries → Enemy staggers → Player grapples → Execute.
Game Loop and Timing Model
The engine uses a fixed-timestep with interpolation model:
Each Frame:
1. Measure wall-clock delta time (capped at 100ms)
2. Feed delta into FixedTime accumulator
3. Run N fixed_update() steps at fixed_dt (default 1/60s)
- Cap at MAX_STEPS=5 to prevent spiral of death
4. Compute interpolation_alpha for render smoothing
5. Call update() with variable delta_time
6. Call render() for sprite submission
7. Call ui() for egui overlay
8. GPU: upload sprites, draw background + sprites + egui
FixedTime
tick: Monotonic 64-bit counter, incremented once per fixed step.fixed_dt: Duration of one fixed step (default1/60s).accumulator: Leftover wall-clock time carried between frames.freeze_frames: When > 0, accumulator is not fed and no fixed steps run. Decrements once per render frame. Used for hitstop and impact freeze.
Interpolation
interpolation_alpha = accumulator / fixed_dt gives a 0.0–1.0 fraction for render-time smoothing between the previous and current physics positions. This ensures silky-smooth visuals even at high refresh rates while keeping physics deterministic.
Rendering Pipeline
┌─────────────────────────────────────────────┐
│ CPU Noise Pass (32×32) │
│ Perlin fog → gradient → upload to GPU │
├─────────────────────────────────────────────┤
│ Full-Screen Quad (background) │
│ Noise texture stretched to viewport │
├─────────────────────────────────────────────┤
│ Instanced Sprite Pass (640×360) │
│ Alpha pipeline → Additive pipeline │
│ Batched by texture_id, up to 1024 sprites │
├─────────────────────────────────────────────┤
│ egui Overlay Pass │
│ Debug UI, menus, HUD │
└─────────────────────────────────────────────┘
Sprite Rendering
Sprites are submitted via ctx.draw_sprite() and ctx.draw_sprite_from_sheet() during render(). They are collected into a Vec<Sprite>, sorted by texture ID, converted to SpriteInstance GPU data, and rendered with instanced draw calls.
Two render pipelines exist within the same render pass:
- Alpha blend (default): Standard transparency
- Additive blend: For effects like hit flashes and glow
Horizontal sprite flipping is done in UV-space (not scale-space) to eliminate anchor-offset artifacts.
Camera
An orthographic camera maps pixel coordinates to NDC space. It supports:
- Horizontal and vertical panning (camera follow)
- Additive screen shakes with exponential decay and Lissajous-like orbits
- Resize handling for window/canvas changes
Physics and Collision
AABB (Axis-Aligned Bounding Box)
The primary collision primitive. Constructed from center + size or top-left + size:
#![allow(unused)] fn main() { let box_a = AABB::new(center, size); let box_b = AABB::from_top_left(top_left, size); }
Operations:
check_collision(): Boolean overlap testget_overlap(): Overlap amount per axisresolve_collision(): Minimum translation vector (MTV) to separate two boxesswept_collision(): Continuous collision detection via Minkowski-expanded ray cast
Collision Layers
#![allow(unused)] fn main() { enum CollisionLayer { Pushbox, Hurtbox, Hitbox, Parrybox } }
BoxVolume pairs a CollisionLayer with a local offset and size, and generates world-space AABBs that auto-flip based on facing direction.
Integration
Physics integration uses decoupled X/Y swept passes to prevent the "floor catch" bug where combined diagonal displacement enters a tile from the wrong axis. One-way platforms only resolve downward collisions.
A skin-width constant prevents the floor the player stands on from being detected as a side wall during the X-sweep.
Input System
Action-Based Design
The engine provides a generic GameAction trait. Each game defines its own action enum and registers key/gamepad bindings at init time, the engine contains no hardcoded actions or default bindings:
#![allow(unused)] fn main() { //? Game-side action definition (e.g. game/src/input.rs) #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum JourneyAction { MoveLeft, MoveRight, MoveUp, MoveDown, Jump, Attack, Block, Dash, Grapple, } impl engine::GameAction for JourneyAction {} }
Bindings are registered in GameApp::init() via InputMap<A>:
#![allow(unused)] fn main() { ctx.input.map.bind_key(Key::ArrowLeft, JourneyAction::MoveLeft); ctx.input.map.bind_key(Key::Space, JourneyAction::Jump); //? Gamepad (native only) ctx.input.map.bind_gamepad_button(gilrs::Button::South, JourneyAction::Jump); ctx.input.map.bind_gamepad_axis_negative(gilrs::Axis::LeftStickX, JourneyAction::MoveLeft); }
Game logic queries actions through the generic InputState<A>, never raw keys:
#![allow(unused)] fn main() { if ctx.input.is_action_just_pressed(JourneyAction::Jump) { ... } if ctx.input.was_action_pressed_buffered(JourneyAction::Jump, buffer_window) { ... } let move_x = ctx.input.get_move_x(); //* -1.0 to 1.0 }
Input Buffering
Both movement and combat inputs support buffering. Movement uses was_action_pressed_buffered() with a configurable time window. Combat uses a dedicated CombatInputBuffer with tick-stamped entries consumed by the FSM.
Platform Support
- Native: Keyboard + mouse + gamepad (via
gilrs) - WASM: Keyboard + mouse only (gamepad planned)
Audio System
Architecture
The audio system wraps Kira with lazy initialization (required on WASM for Web Audio API gesture requirements):
AudioManager
├── Music track (looping, crossfade)
├── Ambience track (looping, crossfade)
├── SFX track (one-shot + looping)
└── UI track (one-shot)
Each track has independent volume control. Music and ambience use handle tracking to prevent overlapping loops.
Event-Driven SFX
The engine provides UiAudioEvent for UI sounds (hover, click, checkbox, tab change), managed via ctx.pending_ui_audio and deduplicated per frame. Game-specific audio events (jump, dash, hit, etc.) are defined and queued entirely by the game crate, keeping the engine free of game-specific coupling.
#![allow(unused)] fn main() { //? Engine UI events (automatic via AudioResponse trait on egui widgets) ui.button("Attack").with_ui_sound(&mut ctx.pending_ui_audio); //? Game events (game-defined enum, game-managed queue) self.pending_game_audio.push(AudioEvent::Jump); }
Sound Loading
All sounds are embedded via include_bytes!() and decoded with load_sound_data() at init time for cross-platform compatibility.
Animation System
Two-Layer Design
-
Engine layer (
engine::animation): Asset-agnosticAnimationDef+AnimationStateruntime. Handles frame timing, playback, looping, and state transitions. Does not know about textures. -
Game layer (
game::anim):Animationstruct that wrapsAnimationDefwith spritesheet-specific logic (grid-basedget_frame_rect()).AnimationStatewraps the engine's state machine and adds frame rectangle calculation.
Spritesheet Layout
The player spritesheet uses a 5-column × 11-row grid at 100×100px per cell:
| Row | Frames | Animation |
|---|---|---|
| 0 | 0–3 | Idle |
| 1 | 5–8 | Run |
| 2 | 10–14 | Jump (10–12 ascend, 13–14 fall) |
| 3 | 15–18 | Death |
| 4 | 20–23 | Parry |
| 5 | 25–28 | Attack Horizontal |
| 6 | 30–33 | Attack Up |
| 7 | 35–38 | Attack Down |
| 8 | 40–43 | Dash |
| 9 | 45–47 | Wall Grab/Slide |
| 10 | 50–51 | Grapple |
Combat animation durations are dynamically derived from FSM frame data so visual playback stays locked to combat timing.
Combat System
Frame-Data FSM
All combat timing uses integer tick counts at the fixed tick rate (default 60Hz). Each move is defined by three phases:
Startup → Active → Recovery
MoveData stores frame counts for each phase, plus damage, knockback, hitbox geometry, and a cancel window percentage. The FSM advances one tick per fixed_update() call and auto-transitions between phases.
Moves
#![allow(unused)] fn main() { enum MoveId { AttackHorizontal, AttackUp, AttackDown, Parry, Dash, Grapple, } }
MoveDatabase holds all move definitions and supports runtime tick-rate scaling. Frame counts are proportionally adjusted so wall-clock timing stays consistent across different tick rates.
Cancel Windows
The last N% of recovery allows cancelling into another move, enabling combo chains. The cancel window percentage is per-move configurable.
Combat Input Buffer
Bridges per-frame input sampling and fixed-rate FSM updates. Inputs are queued with tick stamps and consumed within a configurable frame window (default 20 ticks ≈ 333ms at 60Hz), ensuring rapid inputs are never lost between fixed steps.
Dash Invincibility
Dash grants i-frames during the first half of its active phase, handled within the combat FSM.
Level System
ASCII-Based Level Definition
Levels are defined as ASCII strings where each character maps to a game element:
| Char | Element |
|---|---|
# | Solid platform |
= | One-way platform |
| | Wall |
@ | Player spawn |
G | Grunt enemy |
S | Sniper enemy |
R | Ronin enemy |
O | Grapple node |
X | Exit |
Level Editor
Press F12 to toggle a full-screen dual-mode editor operating on a canonical ASCII string. Features:
- Live minimap with color-coded elements
- Validation pass warning on missing elements
- Universal persistence:
world.txton native,localStorageon WASM
Configuration and Tuning
All physics constants are centralized in game/src/config.rs using a PIXELS_PER_UNIT base (16px) for dimensional consistency:
| Category | Key Constants |
|---|---|
| Movement | MOVEMENT_SPEED (300px/s), MAX_SPEED (300px/s) |
| Jump | JUMP_POWER (600px/s), COYOTE_TICKS (6), JUMP_BUFFER_TICKS (8) |
| Gravity | GRAVITY (1760px/s²), MAX_FALL_SPEED (640px/s) |
| Dash | DASH_SPEED (800px/s), DASH_DURATION_TICKS (8), DASH_COOLDOWN_TICKS (10) |
| Wall | WALL_SLIDE_SPEED (120px/s), WALL_GRAB_TIMEOUT_TICKS (10) |
| Grapple | GRAPPLE_PULL_SPEED (400px/s), GRAPPLE_DETECT_RANGE (144px) |
A runtime-tunable PhysicsConfig struct mirrors these constants and can be modified via the egui debug UI during development.
Cross-Platform Strategy
Conditional Compilation
#![allow(unused)] fn main() { #[cfg(not(target_arch = "wasm32"))] //* Native-only code #[cfg(target_arch = "wasm32")] //* WASM-only code }
Key platform differences:
| Concern | Native | WASM |
|---|---|---|
| GPU init | pollster::block_on (sync) | wasm_bindgen_futures::spawn_local (async) |
| Gamepad | gilrs | Not available |
| Audio init | Immediate at startup | Deferred until user gesture |
| Logging | env_logger | console_log + console_error_panic_hook |
| Fullscreen | winit::Fullscreen | CSS 100vw × 100vh |
| Level persistence | File I/O (world.txt) | web_sys::Storage (localStorage) |
| Splash screen | 2s timer | Skipped |
| Clipboard | OS clipboard via arboard | Not available |
WASM Dependencies
wasm-bindgen/wasm-bindgen-futures: JS interop and async supportweb-sys: DOM manipulation (canvas, storage, window)console_error_panic_hook: Panic messages in DevToolsconsole_log: Rustlogfacade to browser consolegetrandomwithjsfeature: Crypto RNG forrand
Performance Budget
| Metric | Target |
|---|---|
| Frame rate | 60 FPS stable (native + browser) |
| WASM binary | < 5 MB (gzip) |
| Sprite cap | 1024 instanced sprites per frame |
| Physics steps | Max 5 per frame (spiral-of-death cap) |
| Noise resolution | 32×32 CPU pass at ~60Hz |
| Internal resolution | Default 640×360 (configurable via GameApp), nearest-neighbor upscale |
Dependency Map
Engine Crate
| Dependency | Purpose |
|---|---|
wgpu 27 | Cross-platform GPU abstraction |
winit 0.30 | Windowing and event loop |
egui 0.33 / egui-wgpu / egui-winit | Immediate-mode debug UI |
kira 0.12 | Cross-platform audio engine |
glam 0.29 | Fast math (Vec2, Mat4) |
bytemuck 1 | Safe GPU data casting |
image 0.25 | PNG texture loading |
noise 0.8 | Perlin noise generation |
web-time 1 | Cross-platform Instant |
gilrs 0.11 | Gamepad input (native only) |
pollster 0.4 | Async blocking (native only) |
Game Crate
| Dependency | Purpose |
|---|---|
journey-engine (path, aliased as engine) | The engine library |
rand 0.8 | Random number generation |
instant 0.1 | WASM-compatible timing |
log 0.4 | Logging facade |