Introduction
Jugar (Spanish: "to play") is a WASM-native universal game engine built on the Batuta Sovereign AI Stack. It compiles to a single .wasm binary with ABSOLUTE ZERO JavaScript dependencies, making it ideal for secure, deterministic game development.
Why Jugar?
- Pure WASM: Single
.wasmbinary with zero JavaScript - Mobile-First to Ultrawide: Scales from phones to 49" 32:9 monitors
- Batuta Ecosystem: Built on trueno (SIMD/GPU compute) and aprender (ML/AI)
- Extreme TDD: 1500+ tests, 95%+ coverage, mutation testing
- Toyota Way: Quality-first design principles baked in
Key Features
| Feature | Description |
|---|---|
| ECS Architecture | High-performance Entity-Component-System |
| Physics | WebGPU → WASM-SIMD → Scalar tiered backends |
| AI Systems | GOAP, Behavior Trees, Steering Behaviors |
| Responsive UI | Anchor-based layouts for any screen |
| Spatial Audio | 2D positional audio with channel mixing |
| Procgen | Noise, dungeons, Wave Function Collapse |
| Testing | Probar: Rust-native WASM game testing |
Quick Example
use jugar::prelude::*; fn main() { let mut engine = JugarEngine::new(JugarConfig::default()); engine.run(|ctx| { if ctx.input().key_pressed(KeyCode::Escape) { return LoopControl::Exit; } LoopControl::Continue }); }
Design Philosophy: The Toyota Way
"The right process will produce the right results." — Toyota Way
| Principle | Application in Jugar |
|---|---|
| Mieruka (Visual Control) | Telemetry overlays in dev builds |
| Poka-Yoke (Error Proofing) | Type-safe APIs prevent bugs at compile time |
| Jidoka (Autonomation) | Fail-fast on invalid state |
| Heijunka (Leveling) | Fixed timestep for deterministic physics |
| Genchi Genbutsu (Go & See) | Examples as source of truth |
Next Steps
- Quick Start - Get up and running in 5 minutes
- Architecture - Understand the engine structure
- Probar Testing - Learn about WASM-native testing
Quick Start
Get up and running with Jugar in 5 minutes.
Prerequisites
- Rust 1.70+ with
wasm32-unknown-unknowntarget - (Optional)
wasm-packfor web builds
# Add WASM target
rustup target add wasm32-unknown-unknown
# Install wasm-pack (optional, for web builds)
cargo install wasm-pack
Create a New Project
cargo new my-game
cd my-game
Add Dependencies
Add Jugar to your Cargo.toml:
[dependencies]
jugar = "0.1"
Write Your First Game
Replace src/main.rs:
use jugar::prelude::*; fn main() { // Create engine with default 1920x1080 configuration let mut engine = JugarEngine::new(JugarConfig::default()); // Spawn a player entity let player = engine.world_mut().spawn(); engine.world_mut().add_component(player, Position::new(100.0, 100.0)); engine.world_mut().add_component(player, Velocity::new(0.0, 0.0)); // Run the game loop engine.run(|ctx| { // Handle input let input = ctx.input(); let mut vel = Vec2::ZERO; if input.key_held(KeyCode::W) { vel.y -= 1.0; } if input.key_held(KeyCode::S) { vel.y += 1.0; } if input.key_held(KeyCode::A) { vel.x -= 1.0; } if input.key_held(KeyCode::D) { vel.x += 1.0; } // Update velocity component // ... game logic LoopControl::Continue }); }
Build and Run
Native Build
cargo run
WASM Build
# Build for WASM target
cargo build --target wasm32-unknown-unknown --release
# Verify no JavaScript in output
ls target/wasm32-unknown-unknown/release/*.wasm
Next Steps
- Your First Game - Complete game tutorial
- WASM Build - Deploy to the web
- Architecture - Understanding Jugar's design
Installation
Requirements
- Rust: 1.70 or later
- Target:
wasm32-unknown-unknown
Setup
1. Install Rust
If you don't have Rust installed:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
2. Add WASM Target
rustup target add wasm32-unknown-unknown
3. Install Development Tools (Optional)
# For web builds
cargo install wasm-pack
# For quality tooling
cargo install cargo-llvm-cov cargo-mutants cargo-nextest
# Install all tools at once
make install-tools
Adding Jugar to Your Project
From crates.io
[dependencies]
jugar = "0.1"
From Git (Latest)
[dependencies]
jugar = { git = "https://github.com/paiml/jugar" }
Local Development
If you're contributing or developing locally:
[dependencies]
jugar = { path = "../jugar/crates/jugar" }
Feature Flags
Jugar supports optional features:
[dependencies]
jugar = { version = "0.1", features = ["full"] }
| Feature | Description |
|---|---|
default | Core engine functionality |
ai | GOAP and Behavior Trees |
audio | Spatial 2D audio |
procgen | Procedural generation |
full | All features |
Verify Installation
Create a test project:
cargo new jugar-test
cd jugar-test
echo 'jugar = "0.1"' >> Cargo.toml
cargo build --target wasm32-unknown-unknown
If the build succeeds with no JavaScript output, you're ready to go!
Your First Game
Let's build a simple game step by step to understand Jugar's core concepts.
Game Concept
We'll create a simple "Collect the Coins" game:
- Player moves with WASD or arrow keys
- Coins spawn randomly
- Collect coins to increase score
Step 1: Setup
use jugar::prelude::*; // Define game components #[derive(Clone, Copy)] struct Player; #[derive(Clone, Copy)] struct Coin; #[derive(Clone, Copy)] struct Score(u32); fn main() { let config = JugarConfig { width: 800, height: 600, title: "Coin Collector".to_string(), ..Default::default() }; let mut engine = JugarEngine::new(config); // Initialize game state setup(&mut engine); engine.run(game_loop); }
Step 2: Setup Function
#![allow(unused)] fn main() { fn setup(engine: &mut JugarEngine) { let world = engine.world_mut(); // Spawn player at center let player = world.spawn(); world.add_component(player, Player); world.add_component(player, Position::new(400.0, 300.0)); world.add_component(player, Velocity::new(0.0, 0.0)); // Spawn initial coins for _ in 0..5 { spawn_coin(world); } // Add score resource world.add_resource(Score(0)); } fn spawn_coin(world: &mut World) { let x = fastrand::f32() * 800.0; let y = fastrand::f32() * 600.0; let coin = world.spawn(); world.add_component(coin, Coin); world.add_component(coin, Position::new(x, y)); } }
Step 3: Game Loop
#![allow(unused)] fn main() { fn game_loop(ctx: &mut GameContext) -> LoopControl { // Handle input handle_input(ctx); // Update physics update_movement(ctx); // Check collisions check_coin_collection(ctx); LoopControl::Continue } fn handle_input(ctx: &mut GameContext) { let input = ctx.input(); let world = ctx.world_mut(); let speed = 200.0; // pixels per second let mut vel = Vec2::ZERO; if input.key_held(KeyCode::W) || input.key_held(KeyCode::ArrowUp) { vel.y -= speed; } if input.key_held(KeyCode::S) || input.key_held(KeyCode::ArrowDown) { vel.y += speed; } if input.key_held(KeyCode::A) || input.key_held(KeyCode::ArrowLeft) { vel.x -= speed; } if input.key_held(KeyCode::D) || input.key_held(KeyCode::ArrowRight) { vel.x += speed; } // Update player velocity for (_, (_, velocity)) in world.query::<(&Player, &mut Velocity)>() { *velocity = Velocity(vel); } } fn update_movement(ctx: &mut GameContext) { let dt = ctx.delta_time(); let world = ctx.world_mut(); for (_, (pos, vel)) in world.query::<(&mut Position, &Velocity)>() { pos.x += vel.x * dt; pos.y += vel.y * dt; } } fn check_coin_collection(ctx: &mut GameContext) { let world = ctx.world_mut(); let collect_radius = 20.0; // Get player position let player_pos = world.query::<(&Player, &Position)>() .iter() .next() .map(|(_, (_, pos))| *pos); let Some(player_pos) = player_pos else { return }; // Find coins to collect let mut collected = Vec::new(); for (entity, (_, coin_pos)) in world.query::<(&Coin, &Position)>() { let dx = player_pos.x - coin_pos.x; let dy = player_pos.y - coin_pos.y; let dist = (dx * dx + dy * dy).sqrt(); if dist < collect_radius { collected.push(entity); } } // Remove collected coins and update score for entity in collected { world.despawn(entity); if let Some(score) = world.get_resource_mut::<Score>() { score.0 += 1; } // Spawn a new coin spawn_coin(world); } } }
Step 4: Build and Run
# Native
cargo run
# WASM
cargo build --target wasm32-unknown-unknown --release
Testing Your Game
Add tests using Probar:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use jugar_probar::Assertion; #[test] fn test_coin_collection_increases_score() { let mut engine = JugarEngine::new(JugarConfig::default()); setup(&mut engine); let initial_score = engine.world().get_resource::<Score>().unwrap().0; // Simulate collecting a coin // ... test logic let assertion = Assertion::equals(&initial_score, &0); assert!(assertion.passed); } } }
Next Steps
- WASM Build - Deploy to the web
- Architecture - Deep dive into engine design
- Probar Testing - Comprehensive testing
WASM Build
Jugar compiles to pure WASM with ABSOLUTE ZERO JavaScript. This guide covers building and deploying to the web.
Build Commands
Basic WASM Build
cargo build --target wasm32-unknown-unknown --release
Using wasm-pack (Recommended for Web)
wasm-pack build --target web --out-dir pkg
Using Make
# Build WASM
make build-wasm
# Build for web with wasm-pack
make build-web
Output Verification
Verify your build has zero JavaScript:
# Should only show .wasm files
ls target/wasm32-unknown-unknown/release/*.wasm
# Verify with make target
make verify-no-js
Web Deployment
Minimal HTML Loader
Jugar games require a minimal HTML loader that:
- Fetches and instantiates the WASM module
- Forwards browser events to WASM
- Renders output from WASM
Important: The HTML loader contains ZERO game logic. All computation happens in WASM.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>My Jugar Game</title>
<style>
body { margin: 0; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="module">
import init, { WebPlatform } from './pkg/my_game.js';
async function main() {
await init();
const platform = new WebPlatform();
// Event forwarding only - no game logic
document.addEventListener('keydown', e => platform.key_down(e.code));
document.addEventListener('keyup', e => platform.key_up(e.code));
function frame(timestamp) {
const commands = platform.frame(timestamp, '[]');
// Render commands from WASM
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
main();
</script>
</body>
</html>
Local Testing
# Build and serve
make build-web
make serve-web
# Open http://localhost:8080
Performance Targets
| Metric | Target |
|---|---|
| WASM Binary Size | < 2 MiB |
| Gzipped Size | < 500 KB |
| Cold Start | < 100ms |
| Frame Rate | 60 FPS minimum |
Optimization
Size Optimization
In Cargo.toml:
[profile.release]
opt-level = 'z' # Optimize for size
lto = true # Link-time optimization
codegen-units = 1
panic = 'abort'
Use wasm-opt
wasm-opt -Oz -o optimized.wasm input.wasm
Tiered Backend Selection
Jugar automatically selects the best compute backend at runtime:
| Tier | Backend | Capability |
|---|---|---|
| 1 | WebGPU compute shaders | 10,000+ rigid bodies |
| 2 | WASM SIMD 128-bit | 1,000+ rigid bodies |
| 3 | Scalar fallback | Basic physics |
Detection happens automatically via trueno capability probing.
Architecture
Jugar follows a layered architecture built on the Batuta Sovereign AI Stack.
Layer Stack
┌─────────────────────────────────────────────────────────────────────────┐
│ JUGAR WASM BUNDLE (Single .wasm file) │
│ NO JAVASCRIPT WHATSOEVER │
├─────────────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Game Loop │ │ AI Agents │ │ Render │ │ Responsive │ │
│ │ (ECS) │ │ (GOAP/BT) │ │ (Viewport) │ │ UI Layout │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │ │
│ ┌──────┴────────────────┴────────────────┴────────────────┴──────┐ │
│ │ jugar (entry) │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ jugar-core │ jugar-physics │ jugar-ai │ jugar-render │ │
│ │ jugar-input │ jugar-audio │ jugar-ui │ jugar-procgen │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ BATUTA ECOSYSTEM │
│ (trueno v0.7+ / aprender v0.14+) │
└─────────────────────────────────────────────────────────────────────────┘
Crate Structure
| Crate | Description | Dependencies |
|---|---|---|
jugar | Main entry point, JugarEngine | All crates |
jugar-core | ECS, Game Loop, State | hecs, glam |
jugar-physics | Rigid body simulation | trueno |
jugar-ai | GOAP, Behavior Trees | aprender |
jugar-render | Viewport, Anchors | trueno-viz |
jugar-ui | Widget system | jugar-render |
jugar-input | Touch/Mouse/KB/Gamepad | - |
jugar-audio | Spatial 2D audio | - |
jugar-procgen | Noise, Dungeons, WFC | - |
jugar-yaml | Declarative game definitions | - |
jugar-probar | WASM-native testing | wasmtime |
jugar-web | Web platform bindings | wasm-bindgen |
Core Components
JugarEngine
The main engine struct that orchestrates all systems:
#![allow(unused)] fn main() { pub struct JugarEngine { world: World, // ECS world config: JugarConfig, // Engine configuration time: TimeState, // Fixed timestep tracking input: InputState, // Input handling } }
Game Loop
Jugar uses a fixed timestep loop (Heijunka principle):
#![allow(unused)] fn main() { // Internal loop structure loop { // Accumulate time accumulator += delta_time; // Fixed timestep updates while accumulator >= FIXED_DT { physics_update(FIXED_DT); ai_update(FIXED_DT); accumulator -= FIXED_DT; } // Variable timestep render render(accumulator / FIXED_DT); } }
ECS (Entity-Component-System)
Built on hecs for high-performance entity management:
#![allow(unused)] fn main() { // Spawn entity with components let entity = world.spawn(); world.add_component(entity, Position::new(0.0, 0.0)); world.add_component(entity, Velocity::new(1.0, 0.0)); // Query entities for (entity, (pos, vel)) in world.query::<(&mut Position, &Velocity)>() { pos.x += vel.x * dt; pos.y += vel.y * dt; } }
Physics Backend Selection
Runtime capability detection selects the optimal backend:
┌─────────────────────────────────────────────────────────┐
│ Backend Selection (Automatic) │
├─────────────────────────────────────────────────────────┤
│ 1. Check WebGPU support │
│ ├─► Available: Use compute shaders (10K+ bodies) │
│ └─► Not available: Continue │
│ 2. Check WASM SIMD support │
│ ├─► Available: Use SIMD 128-bit (1K+ bodies) │
│ └─► Not available: Continue │
│ 3. Use scalar fallback (basic physics) │
└─────────────────────────────────────────────────────────┘
Responsive Design
Jugar supports mobile-first to 32:9 ultrawide:
Safe Area Calculation
#![allow(unused)] fn main() { // 16:9 gameplay area with peripheral extension let safe_area = viewport.calculate_safe_area(AspectRatio::HD_16_9); let extended = viewport.calculate_extended_area(); // For ultrawide // UI anchors adapt to screen dimensions let anchor = Anchor::BottomCenter { margin: 20.0 }; let pos = anchor.calculate(viewport.dimensions()); }
Universal Scaling Model
- Gameplay Layer: 16:9 safe area with peripheral extension for ultrawide
- UI Layer: Anchor-based responsive layout scaling on shortest dimension
- Input: Touch/Click abstraction with virtual joysticks on touch devices only
Zero JavaScript Policy
Jugar enforces ABSOLUTE ZERO JavaScript in all game computation. This is a critical architectural constraint.
Why Zero JavaScript?
| Problem | JavaScript | Pure WASM |
|---|---|---|
| Determinism | Non-deterministic | Fully deterministic |
| GC Pauses | Unpredictable pauses | No garbage collector |
| Security | Large attack surface | Sandboxed execution |
| Performance | JIT variability | Predictable performance |
| Replay | Difficult | Frame-perfect replay |
What's Forbidden
❌ FORBIDDEN ✅ REQUIRED
─────────────────────────────────────────────────────
• JavaScript files (.js/.ts) • Pure Rust only
• npm/node_modules/package.json • wasm32-unknown-unknown
• Any JS bundler • web-sys (Rust bindings)
• JS interop beyond web-sys • Single .wasm binary output
• wasm-bindgen-futures • Pure async
• gloo, bevy, macroquad, ggez • Batuta stack components
What's Allowed
The only JavaScript permitted is a minimal HTML loader that:
- Fetches and instantiates the WASM module
- Forwards browser events to WASM (keydown, mouse, touch)
- Renders output commands from WASM
Zero game logic in JavaScript. All computation happens in WASM.
Verification
Automated Check
make verify-no-js
This checks for:
- Standalone .js files (excluding wasm-pack output)
- .ts files (excluding .d.ts type definitions)
- package.json in project root
- node_modules directory
- Forbidden crates in Cargo.toml
Manual Verification
# Check for JavaScript files
find . -name "*.js" -not -path "./target/*" -not -path "*/pkg/*"
# Check for TypeScript files
find . -name "*.ts" -not -name "*.d.ts" -not -path "./target/*"
# Check for npm artifacts
ls package.json node_modules 2>/dev/null
Forbidden Crates
These crates violate the zero-JS policy:
| Crate | Reason |
|---|---|
wasm-bindgen-futures | Relies on JS promises |
gloo | JavaScript wrapper library |
bevy | Large with JS dependencies |
macroquad | JavaScript glue required |
ggez | Not pure WASM |
Use Batuta Stack Instead
All functionality comes from the Batuta ecosystem:
| Need | Use |
|---|---|
| SIMD/GPU compute | trueno |
| ML/AI algorithms | aprender |
| Rendering | trueno-viz |
| Platform abstraction | presentar-core |
| Data loading | alimentar |
| Asset registry | pacha |
Benefits
- Deterministic Replay: Record inputs, replay exactly
- Testing: Probar can test without a browser
- Security: No JavaScript attack surface
- Performance: Predictable, no GC pauses
- Size: Single .wasm file < 2MB
Toyota Way Principles
Jugar embodies the Toyota Production System (TPS) principles to ensure quality and reliability.
"The right process will produce the right results." — Toyota Way
Core Principles
Mieruka (Visual Control)
"Make problems visible"
In Jugar:
- Telemetry overlays enabled by default in dev builds
- Visual debugging shows physics bodies, AI paths, collision shapes
- No hidden state - everything is inspectable
#![allow(unused)] fn main() { // Enable debug visualization let config = JugarConfig { debug_overlay: true, show_physics_bodies: true, show_ai_paths: true, ..Default::default() }; }
Poka-Yoke (Error Proofing)
"Mistake-proof the process"
In Jugar:
- Rust's type system prevents null pointers and unhandled errors
Option<T>andResult<T, E>enforce explicit error handling- Type-safe entity selectors in Probar
#![allow(unused)] fn main() { // Compile-time error prevention let entity: Entity = world.spawn(); // Cannot be null let pos: Option<&Position> = world.get_component(entity); // Must handle the Option match pos { Some(p) => println!("Position: {:?}", p), None => println!("Entity has no position"), } }
Jidoka (Autonomation)
"Stop and fix problems immediately"
In Jugar:
- Fail-fast on invalid state with
console_error_panic_hook - Never continue with corrupted state
- Tests fail immediately on assertion failure
#![allow(unused)] fn main() { // Fail-fast on invalid state impl World { pub fn add_component<T>(&mut self, entity: Entity, component: T) { if !self.is_alive(entity) { panic!("Cannot add component to dead entity"); // Jidoka } // ... } } }
Heijunka (Leveling)
"Level the workload"
In Jugar:
- Fixed timestep logic (
fixed_dt) ensures consistent physics - Works identically on 30fps mobile and 144Hz monitors
- No frame-rate dependent behavior
#![allow(unused)] fn main() { const FIXED_DT: f32 = 1.0 / 60.0; // 60 updates per second // Physics runs at fixed rate regardless of frame rate while accumulator >= FIXED_DT { physics_update(FIXED_DT); accumulator -= FIXED_DT; } }
Genchi Genbutsu (Go & See)
"Go to the source to understand"
In Jugar:
examples/directory is the source of truth- Every feature has a working example
- Documentation follows code, not the other way around
# See how physics works
cargo run --example physics_demo
# See how AI works
cargo run --example ai_behavior_tree
# See Probar in action
cargo run --example pong_simulation -p jugar-probar
Just-in-Time (JIT)
"Produce only what is needed, when needed"
In Jugar:
- Assets are streamed on-demand
- Render pipelines are compiled lazily
- Minimal startup time on mobile
#![allow(unused)] fn main() { // Lazy asset loading let texture = assets.load_texture("player.png"); // Deferred // Texture only loads when first used }
Kaizen (Continuous Improvement)
"Always be improving"
In Jugar:
- Hot-reloadable assets for rapid iteration
- State serialization for checkpoint/restore
- Makefile
kaizentarget for improvement analysis
# Run kaizen analysis
make kaizen
# Outputs:
# - Code metrics
# - Coverage analysis
# - Complexity analysis
# - Technical debt grading
# - Improvement recommendations
Quality Metrics
| Metric | Target | Principle |
|---|---|---|
| Test Coverage | ≥95% | Poka-Yoke |
| Mutation Score | ≥80% | Jidoka |
| SATD Comments | 0 | Kaizen |
| Unsafe Code | 0 | Poka-Yoke |
| TDG Grade | A+ | Kaizen |
Tiered Workflow
Inspired by Toyota's quality gates:
# Tier 1: ON-SAVE (sub-second)
make tier1 # Type check + fast tests
# Tier 2: ON-COMMIT (1-5 minutes)
make tier2 # Full validation
# Tier 3: ON-MERGE (hours)
make tier3 # Mutation testing + benchmarks
Batuta Stack Integration
Jugar is built on the Batuta Sovereign AI Stack - a collection of pure Rust crates for AI and compute.
Overview
┌─────────────────────────────────────────────────────────────────┐
│ BATUTA SOVEREIGN AI STACK │
│ (USE THESE COMPONENTS FIRST) │
├─────────────────────────────────────────────────────────────────┤
│ trueno v0.7+ │ SIMD/GPU compute primitives (MANDATORY) │
│ aprender v0.14+ │ ML algorithms, behavior trees (MANDATORY) │
│ trueno-viz │ WebGPU/WebGL2 rendering │
│ presentar-core │ Platform abstraction, event loop │
│ alimentar │ Data loading with encryption │
│ pacha │ Asset registry with signatures │
└─────────────────────────────────────────────────────────────────┘
Mandatory Components
trueno
SIMD and GPU compute primitives. Required for physics acceleration.
# Cargo.toml
[dependencies]
trueno = "0.7"
# Or for local development
trueno = { path = "../batuta/crates/trueno" }
Usage:
#![allow(unused)] fn main() { use trueno::prelude::*; // SIMD-accelerated vector operations let a = Vec4::new(1.0, 2.0, 3.0, 4.0); let b = Vec4::new(5.0, 6.0, 7.0, 8.0); let c = a + b; // Uses SIMD when available }
aprender
ML algorithms and behavior trees. Required for AI systems.
[dependencies]
aprender = "0.14"
Usage:
#![allow(unused)] fn main() { use aprender::prelude::*; // Behavior tree let tree = BehaviorTree::new() .selector() .sequence() .condition(|ctx| ctx.health < 20) .action(|ctx| ctx.flee()) .end() .action(|ctx| ctx.patrol()) .end() .build(); }
Dependency Decision Tree
Need a capability?
│
├─► Does batuta stack have it? ──► YES ──► USE IT (mandatory)
│ │
│ └─► Extend it if needed
│
└─► NO ──► Can we build it in pure Rust? ──► YES ──► Build it
│
└─► NO ──► REJECT
(find another way)
Forbidden Crates
Never import these - they violate zero-JS or use non-Batuta components:
| Crate | Reason |
|---|---|
bevy | Heavy, JS dependencies |
macroquad | JavaScript glue required |
ggez | Not pure WASM |
wasm-bindgen-futures | JS promise dependency |
gloo | JavaScript wrapper |
Version Synchronization
Keep Batuta dependencies in sync:
# Check current versions
cargo tree | grep trueno
cargo tree | grep aprender
# Check latest versions
cargo search trueno
cargo search aprender
# Update
cargo update trueno aprender
Local Development
For contributing to both Jugar and Batuta:
# Cargo.toml - Use local paths
[dependencies]
trueno = { path = "../batuta/crates/trueno" }
aprender = { path = "../batuta/crates/aprender" }
For releases, switch to crates.io:
# Cargo.toml - Use crates.io
[dependencies]
trueno = "0.7"
aprender = "0.14"
Verification
# Verify batuta dependencies are used
make verify-batuta-deps
# Outputs:
# ✅ trueno dependency found
# ✅ aprender dependency found
# ✅ Using local batuta components (recommended for development)
Crates Overview
Jugar is organized as a workspace with multiple specialized crates.
Crate Structure
crates/
├── jugar/ # Main entry point
├── jugar-core/ # ECS, Game Loop, Components
├── jugar-physics/ # Rigid body simulation
├── jugar-ai/ # GOAP, Behavior Trees
├── jugar-render/ # Viewport, Anchors
├── jugar-ui/ # Widget system
├── jugar-input/ # Touch/Mouse/KB/Gamepad
├── jugar-audio/ # Spatial 2D audio
├── jugar-procgen/ # Noise, Dungeons, WFC
├── jugar-yaml/ # Declarative game definitions
├── jugar-probar/ # WASM-native testing
├── jugar-probar-derive/ # Proc-macro for type-safe selectors
├── jugar-web/ # Web platform bindings
└── physics-toy-sandbox/ # Physics demo crate
Summary Table
| Crate | Description | Tests |
|---|---|---|
jugar | Main entry point, JugarEngine | 17 |
jugar-core | ECS, Game Loop, Components | 52 |
jugar-physics | Rigid body simulation | 7 |
jugar-ai | GOAP, Behavior Trees | 17 |
jugar-render | Viewport, Anchors | 10 |
jugar-ui | Widget system | 10 |
jugar-input | Touch/Mouse/KB/Gamepad | 10 |
jugar-audio | Spatial 2D audio | 21 |
jugar-procgen | Noise, Dungeons, WFC | 18 |
jugar-yaml | ELI5 YAML-First declarative games | 334 |
jugar-probar | Rust-native WASM game testing | 128 |
jugar-web | WASM Web platform | 95 |
Total: 1500+ tests
Dependency Graph
jugar (entry)
├── jugar-core
│ └── hecs, glam
├── jugar-physics
│ └── trueno (SIMD/GPU)
├── jugar-ai
│ └── aprender (ML)
├── jugar-render
│ └── trueno-viz
├── jugar-ui
│ └── jugar-render
├── jugar-input
├── jugar-audio
└── jugar-procgen
Feature Flags
Each crate supports optional features:
[dependencies]
jugar = { version = "0.1", features = ["full"] }
| Feature | Includes |
|---|---|
default | Core engine |
ai | jugar-ai |
audio | jugar-audio |
procgen | jugar-procgen |
yaml | jugar-yaml |
full | All features |
jugar
The main entry point crate that provides JugarEngine and the prelude.
Usage
[dependencies]
jugar = "0.1"
JugarEngine
The central engine struct:
use jugar::prelude::*; fn main() { let config = JugarConfig { width: 1920, height: 1080, title: "My Game".to_string(), fixed_dt: 1.0 / 60.0, ..Default::default() }; let mut engine = JugarEngine::new(config); engine.run(|ctx| { // Game logic here if ctx.input().key_pressed(KeyCode::Escape) { return LoopControl::Exit; } LoopControl::Continue }); }
Configuration
#![allow(unused)] fn main() { pub struct JugarConfig { pub width: u32, pub height: u32, pub title: String, pub fixed_dt: f32, // Physics timestep pub debug_overlay: bool, // Show debug info pub vsync: bool, } }
Prelude
The prelude re-exports common types:
#![allow(unused)] fn main() { use jugar::prelude::*; // Includes: // - JugarEngine, JugarConfig // - Position, Velocity, Entity // - KeyCode, MouseButton // - Vec2, Vec3, Mat4 // - LoopControl // - GameContext }
Game Context
Available during the game loop:
#![allow(unused)] fn main() { engine.run(|ctx: &mut GameContext| { // Time let dt = ctx.delta_time(); let total = ctx.total_time(); // Input let input = ctx.input(); // World (ECS) let world = ctx.world(); let world_mut = ctx.world_mut(); LoopControl::Continue }); }
jugar-core
Core engine functionality: ECS, game loop, components, and state management.
ECS (Entity-Component-System)
Built on hecs for high-performance entity management.
Entities
#![allow(unused)] fn main() { use jugar_core::prelude::*; let mut world = World::new(); // Spawn entity let entity = world.spawn(); // Check if alive assert!(world.is_alive(entity)); // Despawn world.despawn(entity); }
Components
#![allow(unused)] fn main() { // Add components world.add_component(entity, Position::new(100.0, 200.0)); world.add_component(entity, Velocity::new(1.0, 0.0)); world.add_component(entity, Health(100)); // Get component if let Some(pos) = world.get_component::<Position>(entity) { println!("Position: {:?}", pos); } // Remove component world.remove_component::<Health>(entity); }
Queries
#![allow(unused)] fn main() { // Query all entities with Position and Velocity for (entity, (pos, vel)) in world.query::<(&mut Position, &Velocity)>() { pos.x += vel.x * dt; pos.y += vel.y * dt; } // Query with filter for (entity, pos) in world.query::<&Position>() .filter::<&Player>() { // Only entities with both Position and Player } }
Built-in Components
| Component | Description |
|---|---|
Position | 2D position (x, y) |
Velocity | 2D velocity (x, y) |
Rotation | Angle in radians |
Scale | Uniform or non-uniform scale |
Transform | Combined position/rotation/scale |
Sprite | Sprite rendering info |
Collider | Collision shape |
RigidBody | Physics body |
Resources
Global state accessible from the world:
#![allow(unused)] fn main() { // Add resource world.add_resource(Score(0)); // Get resource if let Some(score) = world.get_resource::<Score>() { println!("Score: {}", score.0); } // Mutate resource if let Some(score) = world.get_resource_mut::<Score>() { score.0 += 10; } }
Game Loop
Fixed timestep with variable rendering:
#![allow(unused)] fn main() { pub struct GameLoop { fixed_dt: f32, accumulator: f32, } impl GameLoop { pub fn update(&mut self, dt: f32, mut update_fn: impl FnMut(f32)) { self.accumulator += dt; while self.accumulator >= self.fixed_dt { update_fn(self.fixed_dt); self.accumulator -= self.fixed_dt; } } pub fn interpolation_alpha(&self) -> f32 { self.accumulator / self.fixed_dt } } }
jugar-physics
Physics simulation with tiered backend selection.
Backend Tiers
| Tier | Backend | Capability |
|---|---|---|
| 1 | WebGPU compute shaders | 10,000+ rigid bodies |
| 2 | WASM SIMD 128-bit | 1,000+ rigid bodies |
| 3 | Scalar fallback | Basic physics |
Backend is selected automatically at runtime.
Rigid Bodies
#![allow(unused)] fn main() { use jugar_physics::prelude::*; let mut physics = PhysicsWorld::new(); // Create static body let ground = physics.create_static_body( Position::new(400.0, 550.0), Collider::box_shape(800.0, 100.0), ); // Create dynamic body let ball = physics.create_dynamic_body( Position::new(400.0, 100.0), Collider::circle(20.0), RigidBodyConfig { mass: 1.0, restitution: 0.8, friction: 0.3, }, ); }
Collision Detection
Spatial hashing for broad-phase:
#![allow(unused)] fn main() { // Check collisions for contact in physics.get_contacts() { match (contact.body_a, contact.body_b) { (a, b) if a == player && is_coin(b) => { collect_coin(b); } _ => {} } } }
Forces and Impulses
#![allow(unused)] fn main() { // Apply force (continuous) physics.apply_force(body, Vec2::new(0.0, -100.0)); // Apply impulse (instant) physics.apply_impulse(body, Vec2::new(500.0, 0.0)); // Set velocity directly physics.set_velocity(body, Vec2::new(10.0, 0.0)); }
Raycasting
#![allow(unused)] fn main() { let ray = Ray::new( Vec2::new(100.0, 100.0), // origin Vec2::new(1.0, 0.0), // direction ); if let Some(hit) = physics.raycast(ray, 500.0) { println!("Hit body {:?} at {:?}", hit.body, hit.point); } }
Configuration
#![allow(unused)] fn main() { let config = PhysicsConfig { gravity: Vec2::new(0.0, 980.0), // pixels/s² iterations: 8, // solver iterations sleep_threshold: 0.1, // velocity threshold }; let mut physics = PhysicsWorld::with_config(config); }
jugar-ai
AI systems: GOAP, Behavior Trees, and Steering Behaviors.
GOAP (Goal-Oriented Action Planning)
#![allow(unused)] fn main() { use jugar_ai::goap::*; // Define world state let mut state = WorldState::new(); state.set("has_weapon", false); state.set("enemy_dead", false); state.set("at_armory", false); // Define actions let actions = vec![ Action::new("go_to_armory") .precondition("at_armory", false) .effect("at_armory", true) .cost(5), Action::new("get_weapon") .precondition("at_armory", true) .precondition("has_weapon", false) .effect("has_weapon", true) .cost(2), Action::new("attack") .precondition("has_weapon", true) .effect("enemy_dead", true) .cost(1), ]; // Define goal let goal = Goal::new() .require("enemy_dead", true); // Plan let planner = GoapPlanner::new(); let plan = planner.plan(&state, &goal, &actions); // Returns: ["go_to_armory", "get_weapon", "attack"] }
Behavior Trees
#![allow(unused)] fn main() { use jugar_ai::behavior_tree::*; let tree = BehaviorTree::new() .selector() // Try children until one succeeds .sequence() // All children must succeed .condition(|ctx| ctx.health < 20) .action(|ctx| ctx.flee()) .end() .sequence() .condition(|ctx| ctx.sees_enemy()) .action(|ctx| ctx.attack()) .end() .action(|ctx| ctx.patrol()) .end() .build(); // Tick the tree each frame let status = tree.tick(&mut context); match status { Status::Success => { /* completed */ } Status::Running => { /* still executing */ } Status::Failure => { /* failed */ } } }
Node Types
| Node | Description |
|---|---|
Selector | Try children until one succeeds |
Sequence | Run children until one fails |
Parallel | Run all children simultaneously |
Condition | Check a predicate |
Action | Execute behavior |
Decorator | Modify child behavior |
Steering Behaviors
#![allow(unused)] fn main() { use jugar_ai::steering::*; let mut agent = SteeringAgent::new(position, max_speed); // Individual behaviors let seek = agent.seek(target); let flee = agent.flee(danger); let arrive = agent.arrive(destination, slow_radius); let wander = agent.wander(circle_distance, circle_radius); // Combine behaviors let steering = SteeringCombiner::new() .add(seek, 1.0) .add(agent.separation(&neighbors), 2.0) .add(agent.cohesion(&neighbors), 0.5) .add(agent.alignment(&neighbors), 0.5) .calculate(); agent.apply_steering(steering, dt); }
Navigation Mesh
#![allow(unused)] fn main() { use jugar_ai::navmesh::*; // Create navmesh from polygons let navmesh = NavMesh::from_polygons(&walkable_areas); // Find path let path = navmesh.find_path(start, end); // Smooth path let smooth_path = navmesh.smooth_path(&path); }
jugar-render
Viewport management and resolution-independent rendering.
Viewport
#![allow(unused)] fn main() { use jugar_render::prelude::*; let viewport = Viewport::new(1920, 1080); // Safe area for 16:9 gameplay let safe_area = viewport.safe_area(); // Extended area for ultrawide let extended = viewport.extended_area(); // Current aspect ratio let ratio = viewport.aspect_ratio(); }
Aspect Ratio Handling
#![allow(unused)] fn main() { // Define safe gameplay area let gameplay_area = viewport.calculate_safe_area(AspectRatio::HD_16_9); // For 32:9 ultrawide, extends horizontally // For 9:16 portrait, extends vertically }
Anchor System
Position UI elements relative to screen edges:
#![allow(unused)] fn main() { use jugar_render::anchor::*; // Corner anchors let top_left = Anchor::TopLeft { margin: 10.0 }; let bottom_right = Anchor::BottomRight { margin: 10.0 }; // Edge anchors let top_center = Anchor::TopCenter { margin: 20.0 }; let left_center = Anchor::LeftCenter { margin: 20.0 }; // Center let center = Anchor::Center; // Calculate screen position let screen_pos = anchor.calculate(viewport.dimensions()); }
Camera
#![allow(unused)] fn main() { use jugar_render::camera::*; let mut camera = Camera2D::new(viewport.dimensions()); // Follow target camera.follow(player_position, dt); // Smooth follow camera.follow_smooth(player_position, 0.1, dt); // Zoom camera.set_zoom(2.0); // Convert coordinates let world_pos = camera.screen_to_world(mouse_pos); let screen_pos = camera.world_to_screen(entity_pos); }
Responsive Scaling
#![allow(unused)] fn main() { // Scale UI based on shortest dimension let ui_scale = viewport.ui_scale_factor(); // Scale gameplay based on reference resolution let game_scale = viewport.game_scale_factor(1920, 1080); }
Render Commands
Jugar uses a command-based rendering system:
#![allow(unused)] fn main() { pub enum RenderCommand { DrawSprite { texture: TextureId, position: Vec2, rotation: f32, scale: Vec2, color: Color, }, DrawRect { position: Vec2, size: Vec2, color: Color, }, DrawText { text: String, position: Vec2, font_size: f32, color: Color, }, // ... } }
Commands are serialized to JSON and sent to the HTML renderer.
jugar-ui
Widget system for responsive game UI.
Widgets
#![allow(unused)] fn main() { use jugar_ui::prelude::*; // Text label let label = Label::new("Score: 100") .font_size(24.0) .color(Color::WHITE); // Button let button = Button::new("Start Game") .size(200.0, 50.0) .on_click(|| { println!("Button clicked!"); }); // Progress bar let health_bar = ProgressBar::new(0.75) .size(200.0, 20.0) .foreground(Color::RED) .background(Color::DARK_GRAY); }
Layout
#![allow(unused)] fn main() { // Horizontal layout let h_layout = HBox::new() .spacing(10.0) .add(button1) .add(button2) .add(button3); // Vertical layout let v_layout = VBox::new() .spacing(5.0) .add(title) .add(health_bar) .add(score_label); // Grid layout let grid = Grid::new(3, 3) .cell_size(64.0, 64.0) .spacing(4.0); }
Panels
#![allow(unused)] fn main() { // Container panel let panel = Panel::new() .size(300.0, 200.0) .background(Color::rgba(0, 0, 0, 180)) .padding(10.0) .add(v_layout); // Anchored panel let hud = Panel::new() .anchor(Anchor::TopLeft { margin: 10.0 }) .add(health_bar) .add(score_label); }
Responsive Scaling
UI automatically scales based on screen size:
#![allow(unused)] fn main() { let ui = UiContext::new(viewport); // Scale factor based on shortest dimension let scale = ui.scale_factor(); // Reference resolution scaling let scaled_size = ui.scale_from_reference(100.0, 1920); }
Input Handling
#![allow(unused)] fn main() { impl Widget for Button { fn handle_input(&mut self, input: &InputState) -> bool { let mouse_pos = input.mouse_position(); if self.bounds.contains(mouse_pos) { if input.mouse_just_pressed(MouseButton::Left) { self.on_click.call(); return true; } } false } } }
Custom Widgets
#![allow(unused)] fn main() { struct MiniMap { position: Vec2, size: Vec2, entities: Vec<MinimapEntity>, } impl Widget for MiniMap { fn update(&mut self, dt: f32) { // Update minimap entities } fn render(&self) -> Vec<RenderCommand> { let mut commands = vec![]; commands.push(RenderCommand::DrawRect { position: self.position, size: self.size, color: Color::rgba(0, 0, 0, 150), }); // Draw entities as dots for entity in &self.entities { commands.push(RenderCommand::DrawCircle { position: entity.minimap_pos, radius: 3.0, color: entity.color, }); } commands } } }
jugar-input
Unified input handling for touch, mouse, keyboard, and gamepad.
Keyboard
#![allow(unused)] fn main() { use jugar_input::prelude::*; let input = InputState::new(); // Key states if input.key_pressed(KeyCode::Space) { // Just pressed this frame } if input.key_held(KeyCode::W) { // Currently held down } if input.key_released(KeyCode::Escape) { // Just released this frame } }
Mouse
#![allow(unused)] fn main() { // Position let pos = input.mouse_position(); // Buttons if input.mouse_pressed(MouseButton::Left) { spawn_projectile(pos); } if input.mouse_held(MouseButton::Right) { aim_at(pos); } // Scroll wheel let scroll = input.scroll_delta(); camera.zoom += scroll.y * 0.1; }
Touch
#![allow(unused)] fn main() { // All active touches for touch in input.touches() { match touch.phase { TouchPhase::Started => { // New touch } TouchPhase::Moved => { // Touch moved let delta = touch.position - touch.previous_position; } TouchPhase::Ended | TouchPhase::Cancelled => { // Touch ended } } } // Gestures if let Some(pinch) = input.pinch_gesture() { camera.zoom *= pinch.scale; } if let Some(pan) = input.pan_gesture() { camera.position -= pan.delta; } }
Gamepad
#![allow(unused)] fn main() { // Check if gamepad connected if let Some(gamepad) = input.gamepad(0) { // Buttons if gamepad.button_pressed(GamepadButton::South) { player.jump(); } // Analog sticks let left_stick = gamepad.left_stick(); player.velocity.x = left_stick.x * max_speed; let right_stick = gamepad.right_stick(); player.aim_direction = right_stick.normalize(); // Triggers let fire_intensity = gamepad.right_trigger(); if fire_intensity > 0.5 { player.fire(); } } }
Virtual Joystick
For touch devices:
#![allow(unused)] fn main() { let mut joystick = VirtualJoystick::new() .position(100.0, 400.0) .radius(60.0) .dead_zone(0.1); // Update with touch input joystick.update(&input); // Get direction let direction = joystick.direction(); player.velocity = direction * max_speed; }
Input Abstraction
Unify different input methods:
#![allow(unused)] fn main() { // Abstract action let move_input = input.get_axis_2d( // Keyboard KeyCode::W, KeyCode::S, KeyCode::A, KeyCode::D, // Gamepad GamepadAxis::LeftStickX, GamepadAxis::LeftStickY, ); // Returns normalized Vec2 from any input source player.velocity = move_input * max_speed; }
Key Codes
Common key codes:
| Category | Keys |
|---|---|
| Letters | A - Z |
| Numbers | Key0 - Key9 |
| Arrows | ArrowUp, ArrowDown, ArrowLeft, ArrowRight |
| Special | Space, Enter, Escape, Tab, Backspace |
| Modifiers | ShiftLeft, ControlLeft, AltLeft |
| Function | F1 - F12 |
jugar-audio
Spatial 2D audio with channel mixing.
Basic Playback
#![allow(unused)] fn main() { use jugar_audio::prelude::*; let mut audio = AudioEngine::new(); // Load sound let sound = audio.load_sound("explosion.wav"); // Play sound audio.play(sound); // Play with volume audio.play_with_volume(sound, 0.5); }
Channels
#![allow(unused)] fn main() { // Audio channels pub enum Channel { Master, // Overall volume Music, // Background music Effects, // Sound effects Voice, // Dialogue Ambient, // Environmental sounds } // Set channel volume audio.set_channel_volume(Channel::Music, 0.7); audio.set_channel_volume(Channel::Effects, 1.0); // Play on specific channel audio.play_on_channel(sound, Channel::Effects); }
Spatial Audio
#![allow(unused)] fn main() { // Create audio source at position let source = audio.create_source(Position::new(500.0, 300.0)); audio.play_at_source(sound, source); // Set listener position (usually player) audio.set_listener_position(player_position); // Update source position audio.set_source_position(source, enemy_position); }
Attenuation
#![allow(unused)] fn main() { // Configure distance attenuation let source = audio.create_source_with_config( position, SpatialConfig { min_distance: 50.0, // Full volume within this range max_distance: 500.0, // Inaudible beyond this range rolloff: Rolloff::Linear, }, ); }
Rolloff Models
| Model | Description |
|---|---|
Linear | Linear falloff between min and max |
Inverse | 1/distance falloff |
InverseSquare | 1/distance² (realistic) |
None | No distance attenuation |
Stereo Panning
#![allow(unused)] fn main() { // Automatic panning based on position // Sounds to the left of listener pan left // Sounds to the right pan right // Manual pan (-1.0 = left, 0.0 = center, 1.0 = right) audio.set_pan(source, -0.5); }
Music
#![allow(unused)] fn main() { // Background music (loops by default) let music = audio.load_music("background.ogg"); audio.play_music(music); // Crossfade to new track audio.crossfade_music(new_music, 2.0); // 2 second fade // Pause/resume audio.pause_music(); audio.resume_music(); }
Sound Groups
#![allow(unused)] fn main() { // Create a group for related sounds let footsteps = SoundGroup::new() .add(audio.load_sound("step1.wav")) .add(audio.load_sound("step2.wav")) .add(audio.load_sound("step3.wav")); // Play random sound from group footsteps.play_random(&mut audio); // Play sequential footsteps.play_next(&mut audio); }
Example: Complete Setup
#![allow(unused)] fn main() { fn setup_audio(audio: &mut AudioEngine) { // Set channel volumes audio.set_channel_volume(Channel::Master, 0.8); audio.set_channel_volume(Channel::Music, 0.5); audio.set_channel_volume(Channel::Effects, 1.0); // Start background music let music = audio.load_music("theme.ogg"); audio.play_music(music); } fn update_audio(audio: &mut AudioEngine, player_pos: Position) { // Update listener to follow player audio.set_listener_position(player_pos); } fn play_explosion(audio: &mut AudioEngine, position: Position) { let sound = audio.load_sound("explosion.wav"); let source = audio.create_source(position); audio.play_at_source_on_channel(sound, source, Channel::Effects); } }
jugar-procgen
Procedural generation: noise, dungeons, and Wave Function Collapse.
Value Noise
#![allow(unused)] fn main() { use jugar_procgen::noise::*; let noise = ValueNoise::new(seed); // Single value at point let value = noise.sample(x, y); // Returns 0.0 to 1.0 // With octaves (fractal noise) let config = NoiseConfig { octaves: 4, persistence: 0.5, // Amplitude decrease per octave lacunarity: 2.0, // Frequency increase per octave scale: 0.01, }; let value = noise.sample_octaves(x, y, &config); }
Noise Types
#![allow(unused)] fn main() { // Value noise (smooth) let value = ValueNoise::new(seed); // Perlin noise (gradient-based) let perlin = PerlinNoise::new(seed); // Simplex noise (faster, less artifacts) let simplex = SimplexNoise::new(seed); // Worley noise (cellular) let worley = WorleyNoise::new(seed); }
Generating Terrain
#![allow(unused)] fn main() { fn generate_heightmap(width: usize, height: usize, seed: u64) -> Vec<f32> { let noise = SimplexNoise::new(seed); let config = NoiseConfig::default(); let mut heightmap = vec![0.0; width * height]; for y in 0..height { for x in 0..width { let value = noise.sample_octaves( x as f32, y as f32, &config, ); heightmap[y * width + x] = value; } } heightmap } }
Dungeon Generation
#![allow(unused)] fn main() { use jugar_procgen::dungeon::*; // BSP-based dungeon let config = DungeonConfig { width: 80, height: 60, min_room_size: 5, max_room_size: 15, corridor_width: 2, }; let dungeon = BspDungeon::generate(config, seed); // Access tiles for y in 0..dungeon.height { for x in 0..dungeon.width { match dungeon.get_tile(x, y) { Tile::Floor => { /* walkable */ } Tile::Wall => { /* blocked */ } Tile::Door => { /* door */ } } } } // Get rooms for room in dungeon.rooms() { spawn_enemies_in_room(room); } }
Wave Function Collapse
#![allow(unused)] fn main() { use jugar_procgen::wfc::*; // Define tiles and rules let tiles = vec![ Tile::new("grass").edges(["g", "g", "g", "g"]), Tile::new("water").edges(["w", "w", "w", "w"]), Tile::new("shore_n").edges(["g", "g", "w", "g"]), // ... more tiles with edge constraints ]; let wfc = WaveFunctionCollapse::new(tiles); // Generate map let result = wfc.generate(width, height, seed); for y in 0..height { for x in 0..width { let tile = result.get(x, y); render_tile(tile, x, y); } } }
WFC Constraints
#![allow(unused)] fn main() { // Socket-based constraints let tiles = vec![ Tile::new("road_horizontal") .sockets(Socket { north: "grass", south: "grass", east: "road", west: "road", }), Tile::new("road_vertical") .sockets(Socket { north: "road", south: "road", east: "grass", west: "grass", }), // ... ]; }
Room Placement
#![allow(unused)] fn main() { use jugar_procgen::rooms::*; let mut placer = RoomPlacer::new(100, 100); // Add rooms with constraints placer.add_room(Room::new(10, 10).tag("spawn")); placer.add_room(Room::new(15, 15).tag("boss")); placer.add_rooms(5, Room::random(5..10, 5..10).tag("normal")); // Connect rooms placer.connect_all_rooms(); // Generate let layout = placer.generate(seed); }
Combining Techniques
#![allow(unused)] fn main() { fn generate_world(seed: u64) -> World { // 1. Generate heightmap with noise let heightmap = generate_heightmap(256, 256, seed); // 2. Generate dungeon layout let dungeon = BspDungeon::generate(config, seed); // 3. Use WFC to fill in details let details = wfc.generate(dungeon.width, dungeon.height, seed); // 4. Combine everything // ... } }
jugar-yaml
ELI5 YAML-First declarative game definitions. Define entire games in YAML.
Overview
jugar-yaml provides a declarative way to define games using YAML configuration files. This follows the "ELI5" (Explain Like I'm 5) philosophy - making game development accessible.
Basic Structure
game:
title: "My First Game"
width: 800
height: 600
background: "#1a1a2e"
entities:
- name: player
position: [400, 300]
sprite: "player.png"
components:
- type: velocity
value: [0, 0]
- type: collider
shape: circle
radius: 20
- name: enemy
position: [100, 100]
sprite: "enemy.png"
ai:
behavior: patrol
waypoints:
- [100, 100]
- [700, 100]
- [700, 500]
- [100, 500]
Loading Games
#![allow(unused)] fn main() { use jugar_yaml::prelude::*; // Load from file let game = GameDefinition::load("game.yaml")?; // Create engine from definition let engine = JugarEngine::from_definition(game); engine.run(game_loop); }
Components
Define components declaratively:
entities:
- name: ball
position: [400, 300]
components:
# Physics
- type: rigid_body
mass: 1.0
restitution: 0.9
# Rendering
- type: sprite
texture: "ball.png"
scale: [1.0, 1.0]
# Custom data
- type: custom
data:
health: 100
damage: 10
Behaviors
AI behaviors in YAML:
entities:
- name: guard
ai:
behavior: behavior_tree
tree:
type: selector
children:
- type: sequence
children:
- type: condition
check: "health < 20"
- type: action
do: flee
- type: sequence
children:
- type: condition
check: "sees_player"
- type: action
do: attack
- type: action
do: patrol
Input Mapping
input:
actions:
move_up:
- key: W
- key: ArrowUp
- gamepad: LeftStickUp
move_down:
- key: S
- key: ArrowDown
- gamepad: LeftStickDown
fire:
- key: Space
- mouse: Left
- gamepad: South
Scenes
Define multiple scenes:
scenes:
main_menu:
entities:
- name: title
position: [400, 200]
text: "My Game"
font_size: 48
- name: start_button
position: [400, 350]
button:
text: "Start"
action: load_scene:gameplay
gameplay:
entities:
- name: player
# ...
Templates
Reusable entity templates:
templates:
coin:
sprite: "coin.png"
components:
- type: collider
shape: circle
radius: 15
- type: custom
data:
value: 10
entities:
- template: coin
position: [100, 200]
- template: coin
position: [200, 200]
- template: coin
position: [300, 200]
components:
- type: custom
data:
value: 50 # Override
Physics World
physics:
gravity: [0, 980]
iterations: 8
static_bodies:
- name: ground
position: [400, 580]
shape:
type: box
size: [800, 40]
- name: left_wall
position: [0, 300]
shape:
type: box
size: [20, 600]
Validation
The YAML is validated at load time:
#![allow(unused)] fn main() { let result = GameDefinition::load("game.yaml"); match result { Ok(game) => { /* valid */ } Err(YamlError::InvalidPosition(msg)) => { println!("Position error: {}", msg); } Err(YamlError::MissingField(field)) => { println!("Missing required field: {}", field); } // ... } }
Example: Complete Pong
game:
title: "YAML Pong"
width: 800
height: 600
physics:
gravity: [0, 0]
entities:
- name: ball
position: [400, 300]
components:
- type: velocity
value: [300, 200]
- type: collider
shape: circle
radius: 10
- name: paddle_left
position: [50, 300]
components:
- type: collider
shape: box
size: [10, 80]
input:
up: W
down: S
- name: paddle_right
position: [750, 300]
components:
- type: collider
shape: box
size: [10, 80]
ai:
behavior: follow_ball
speed: 200
ui:
- name: score
position: [400, 50]
text: "0 - 0"
font_size: 32
jugar-web
Web platform bindings for running Jugar games in the browser.
Overview
jugar-web provides the WASM bindings for running Jugar games in web browsers. It handles:
- WASM module initialization
- Event forwarding from browser to game
- Render command output to JavaScript
- WebAudio integration
- Touch/mouse/keyboard input
WebPlatform
The main interface exposed to JavaScript:
#![allow(unused)] fn main() { use wasm_bindgen::prelude::*; #[wasm_bindgen] pub struct WebPlatform { game: GameState, config: WebConfig, } #[wasm_bindgen] impl WebPlatform { #[wasm_bindgen(constructor)] pub fn new(width: u32, height: u32) -> Self { // Initialize platform } pub fn frame(&mut self, timestamp: f64, input_json: &str) -> String { // Process frame, return render commands as JSON } pub fn key_down(&mut self, code: &str) { // Handle key press } pub fn key_up(&mut self, code: &str) { // Handle key release } pub fn mouse_move(&mut self, x: f32, y: f32) { // Handle mouse movement } pub fn touch_start(&mut self, id: u32, x: f32, y: f32) { // Handle touch start } } }
Building
# Build with wasm-pack
wasm-pack build crates/jugar-web --target web --out-dir pkg
# Or use make
make build-web
HTML Integration
Minimal loader (no game logic in JS):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Jugar Game</title>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="module">
import init, { WebPlatform } from './pkg/jugar_web.js';
async function main() {
await init();
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const platform = new WebPlatform(800, 600);
// Event forwarding only
document.addEventListener('keydown', e => {
platform.key_down(e.code);
});
document.addEventListener('keyup', e => {
platform.key_up(e.code);
});
canvas.addEventListener('mousemove', e => {
platform.mouse_move(e.offsetX, e.offsetY);
});
function frame(timestamp) {
const commands = platform.frame(timestamp, '[]');
render(ctx, JSON.parse(commands));
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
function render(ctx, commands) {
for (const cmd of commands) {
switch (cmd.type) {
case 'clear':
ctx.fillStyle = cmd.color;
ctx.fillRect(0, 0, 800, 600);
break;
case 'rect':
ctx.fillStyle = cmd.color;
ctx.fillRect(cmd.x, cmd.y, cmd.w, cmd.h);
break;
case 'text':
ctx.fillStyle = cmd.color;
ctx.font = `${cmd.size}px sans-serif`;
ctx.fillText(cmd.text, cmd.x, cmd.y);
break;
}
}
}
main();
</script>
</body>
</html>
Render Commands
JSON format for render commands:
[
{"type": "clear", "color": "#1a1a2e"},
{"type": "rect", "x": 100, "y": 200, "w": 50, "h": 50, "color": "#ffffff"},
{"type": "text", "x": 400, "y": 50, "text": "Score: 100", "size": 24, "color": "#ffffff"},
{"type": "circle", "x": 300, "y": 300, "r": 10, "color": "#ff0000"}
]
Audio Commands
[
{"type": "playTone", "frequency": 440, "duration": 0.1},
{"type": "playSound", "id": "explosion", "volume": 0.8}
]
Testing
E2E tests using Probar:
# Run Probar tests (replaces Playwright)
make test-e2e
# Or directly
cargo test -p jugar-web --test probar_pong
Configuration
#![allow(unused)] fn main() { pub struct WebConfig { pub width: u32, pub height: u32, pub canvas_id: String, pub audio_enabled: bool, pub touch_enabled: bool, } }
Feature Flags
[features]
default = ["audio"]
audio = [] # Enable WebAudio support
debug = [] # Enable debug overlays
Probar: WASM-Native Game Testing
Probar (Spanish: "to test/prove") is a pure Rust testing framework for WASM games that provides full Playwright feature parity while adding WASM-native capabilities.
Why Probar?
| Aspect | Playwright | Probar |
|---|---|---|
| Language | TypeScript | Pure Rust |
| Browser | Required (Chromium) | Not needed |
| Game State | Black box (DOM only) | Direct API access |
| CI Setup | Node.js + browser | Just cargo test |
| Zero JS | ❌ Violates constraint | ✅ Pure Rust |
Key Features
Playwright Parity
- CSS, text, testid, XPath, role-based locators
- All standard assertions (visibility, text, count)
- All actions (click, fill, type, hover, drag)
- Auto-waiting with configurable timeouts
- Network interception and mobile emulation
WASM-Native Extensions
- Zero-copy memory views - Direct WASM memory inspection
- Type-safe entity selectors - Compile-time verified game object access
- Deterministic replay - Record inputs with seed, replay identically
- Invariant fuzzing - Concolic testing for game invariants
- Frame-perfect timing - Fixed timestep control
- WCAG accessibility - Color contrast and photosensitivity checking
Quick Example
#![allow(unused)] fn main() { use jugar_probar::Assertion; use jugar_web::{WebConfig, WebPlatform}; #[test] fn test_game_starts() { let config = WebConfig::new(800, 600); let mut platform = WebPlatform::new_for_test(config); // Run a frame let output = platform.frame(0.0, "[]"); // Verify output assert!(output.contains("commands")); // Use Probar assertions let assertion = Assertion::in_range(60.0, 0.0, 100.0); assert!(assertion.passed); } }
Running Tests
# All Probar E2E tests
cargo test -p jugar-web --test probar_pong
# Verbose output
cargo test -p jugar-web --test probar_pong -- --nocapture
# Via Makefile
make test-e2e
make test-e2e-verbose
Test Suites
| Suite | Tests | Coverage |
|---|---|---|
| Pong WASM Game (Core) | 6 | WASM loading, rendering, input |
| Pong Demo Features | 22 | Game modes, HUD, AI widgets |
| Release Readiness | 11 | Stress tests, performance, edge cases |
Total: 39 tests
Architecture
Dual-Runtime Strategy
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ WasmRuntime (wasmtime) │ │ BrowserController (Chrome) │
│ ───────────────────────── │ │ ───────────────────────── │
│ Purpose: LOGIC-ONLY testing │ │ Purpose: GOLDEN MASTER │
│ │ │ │
│ ✓ Unit tests │ │ ✓ E2E tests │
│ ✓ Deterministic replay │ │ ✓ Visual regression │
│ ✓ Invariant fuzzing │ │ ✓ Browser compatibility │
│ ✓ Performance benchmarks │ │ ✓ Production parity │
│ │ │ │
│ ✗ NOT for rendering │ │ This is the SOURCE OF TRUTH │
│ ✗ NOT for browser APIs │ │ for "does it work?" │
└─────────────────────────────────┘ └─────────────────────────────────┘
Toyota Way Principles
| Principle | Application |
|---|---|
| Poka-Yoke | Type-safe selectors prevent typos at compile time |
| Muda | Zero-copy memory views eliminate serialization |
| Genchi Genbutsu | ProbarDriver abstraction for swappable backends |
| Andon Cord | Fail-fast mode stops on first critical failure |
| Jidoka | Quality built into the type system |
Next Steps
- Why Probar? - Detailed comparison with Playwright
- Quick Start - Get started testing
- Assertions - Available assertion types
- Coverage Tooling - Advanced coverage analysis
Why Probar?
Probar was created as a complete replacement for Playwright in the Jugar ecosystem.
The Problem with Playwright
- JavaScript Dependency: Playwright requires Node.js and npm
- Browser Overhead: Must download and run Chromium
- Black Box Testing: Can only inspect DOM, not game state
- CI Complexity: Requires browser installation in CI
- Violates Zero-JS: Contradicts Jugar's core constraint
What Probar Solves
Zero JavaScript
Before (Playwright):
├── package.json
├── node_modules/
├── tests/
│ └── pong.spec.ts ← TypeScript!
└── playwright.config.ts
After (Probar):
└── tests/
└── probar_pong.rs ← Pure Rust!
Direct State Access
Playwright treats the game as a black box:
// Can only check DOM
await expect(page.locator('#score')).toHaveText('10');
Probar can inspect game state directly:
#![allow(unused)] fn main() { // Direct access to game internals let score = platform.get_game_state().score; assert_eq!(score, 10); // Check entity positions for entity in platform.query_entities::<Ball>() { assert!(entity.position.y < 600.0); } }
Deterministic Testing
Playwright: Non-deterministic due to browser timing
// Flaky! Timing varies between runs
await page.waitForTimeout(100);
await expect(ball).toBeVisible();
Probar: Fully deterministic
#![allow(unused)] fn main() { // Exact frame control for _ in 0..100 { platform.advance_frame(1.0 / 60.0); } let ball_pos = platform.get_ball_position(); assert_eq!(ball_pos, expected_pos); // Always passes }
Simpler CI
Playwright CI:
- name: Install Node.js
uses: actions/setup-node@v3
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install chromium
- name: Run tests
run: npm test
Probar CI:
- name: Run tests
run: cargo test
Feature Comparison
| Feature | Playwright | Probar |
|---|---|---|
| Language | TypeScript | Pure Rust |
| Browser required | Yes | No |
| Game state access | DOM only | Direct |
| Deterministic | No | Yes |
| CI setup | Complex | Simple |
| Frame control | Approximate | Exact |
| Memory inspection | No | Yes |
| Replay support | No | Yes |
| Fuzzing | No | Yes |
Migration Example
Before (Playwright)
import { test, expect } from '@playwright/test';
test('ball bounces off walls', async ({ page }) => {
await page.goto('http://localhost:8080');
// Wait for game to load
await page.waitForSelector('#game-canvas');
// Simulate gameplay
await page.waitForTimeout(2000);
// Check score changed (indirect verification)
const score = await page.locator('#score').textContent();
expect(parseInt(score)).toBeGreaterThan(0);
});
After (Probar)
#![allow(unused)] fn main() { #[test] fn ball_bounces_off_walls() { let mut platform = WebPlatform::new_for_test(WebConfig::default()); // Advance exactly 120 frames (2 seconds at 60fps) for _ in 0..120 { platform.advance_frame(1.0 / 60.0); } // Direct state verification let state = platform.get_game_state(); assert!(state.ball_bounces > 0, "Ball should have bounced"); assert!(state.score > 0, "Score should have increased"); } }
Performance Comparison
| Metric | Playwright | Probar |
|---|---|---|
| Test startup | ~3s | ~0.1s |
| Per-test overhead | ~500ms | ~10ms |
| 39 tests total | ~45s | ~3s |
| CI setup time | ~2min | 0 |
| Memory usage | ~500MB | ~50MB |
When to Use Each
Use Probar for:
- Unit tests
- Integration tests
- Deterministic replay
- Fuzzing
- Performance benchmarks
- CI/CD pipelines
Use Browser Testing for:
- Visual regression (golden master)
- Cross-browser compatibility
- Real user interaction testing
- Production smoke tests
Probar Quick Start
Get started with Probar testing in 5 minutes.
Add Dependency
[dev-dependencies]
jugar-probar = { path = "../jugar-probar" }
Write Your First Test
#![allow(unused)] fn main() { use jugar_probar::Assertion; use jugar_web::{WebConfig, WebPlatform}; #[test] fn test_game_initializes() { // Create test platform let config = WebConfig::new(800, 600); let mut platform = WebPlatform::new_for_test(config); // Run initial frame let output = platform.frame(0.0, "[]"); // Verify game started assert!(output.contains("commands")); } }
Run Tests
# Run all Probar tests
cargo test -p jugar-web --test probar_pong
# With verbose output
cargo test -p jugar-web --test probar_pong -- --nocapture
# Via Makefile
make test-e2e
Test Structure
Basic Assertions
#![allow(unused)] fn main() { use jugar_probar::Assertion; #[test] fn test_assertions() { // Equality let eq = Assertion::equals(&42, &42); assert!(eq.passed); // Range let range = Assertion::in_range(50.0, 0.0, 100.0); assert!(range.passed); // Boolean let truthy = Assertion::is_true(true); assert!(truthy.passed); // Approximate equality (for floats) let approx = Assertion::approx_eq(3.14, 3.14159, 0.01); assert!(approx.passed); } }
Testing Game Logic
#![allow(unused)] fn main() { #[test] fn test_ball_movement() { let mut platform = WebPlatform::new_for_test(WebConfig::default()); // Get initial position let initial_pos = platform.get_ball_position(); // Advance 60 frames (1 second) for _ in 0..60 { platform.advance_frame(1.0 / 60.0); } // Ball should have moved let new_pos = platform.get_ball_position(); assert_ne!(initial_pos, new_pos); } }
Testing Input
#![allow(unused)] fn main() { #[test] fn test_paddle_responds_to_input() { let mut platform = WebPlatform::new_for_test(WebConfig::default()); let initial_y = platform.get_paddle_y(Player::Left); // Simulate pressing W key platform.key_down("KeyW"); for _ in 0..30 { platform.advance_frame(1.0 / 60.0); } platform.key_up("KeyW"); // Paddle should have moved up let new_y = platform.get_paddle_y(Player::Left); assert!(new_y < initial_y); } }
Examples
Run the included examples:
# Deterministic simulation with replay
cargo run --example pong_simulation -p jugar-probar
# Locator API demo
cargo run --example locator_demo -p jugar-probar
# Accessibility checking
cargo run --example accessibility_demo -p jugar-probar
# Coverage demo
cargo run --example coverage_demo -p jugar-probar
Example Output
=== Probar Pong Simulation Demo ===
--- Demo 1: Pong Simulation ---
Initial state:
Ball: (400.0, 300.0)
Paddles: P1=300.0, P2=300.0
Score: 0 - 0
Simulating 300 frames...
Final state after 300 frames:
Ball: (234.5, 412.3)
Paddles: P1=180.0, P2=398.2
Score: 2 - 1
State valid: true
--- Demo 2: Deterministic Replay ---
Recording simulation (seed=42, frames=500)...
Completed: true
Final hash: 6233835744931225727
Replaying simulation...
Determinism verified: true
Hashes match: true
Next Steps
- Assertions - All assertion types
- Simulation - Deterministic simulation
- Fuzzing - Random testing
- Coverage Tooling - Code coverage
Assertions
Probar provides a rich set of assertions for testing game state.
Basic Assertions
#![allow(unused)] fn main() { use jugar_probar::Assertion; // Equality let eq = Assertion::equals(&actual, &expected); assert!(eq.passed); assert_eq!(eq.message, "Values are equal"); // Inequality let ne = Assertion::not_equals(&a, &b); // Boolean let truthy = Assertion::is_true(condition); let falsy = Assertion::is_false(condition); }
Numeric Assertions
#![allow(unused)] fn main() { // Range check let range = Assertion::in_range(value, min, max); // Approximate equality (for floats) let approx = Assertion::approx_eq(3.14159, std::f64::consts::PI, 0.001); // Greater/Less than let gt = Assertion::greater_than(value, threshold); let lt = Assertion::less_than(value, threshold); let gte = Assertion::greater_than_or_equal(value, threshold); let lte = Assertion::less_than_or_equal(value, threshold); }
Collection Assertions
#![allow(unused)] fn main() { // Contains let contains = Assertion::contains(&collection, &item); // Length let len = Assertion::has_length(&vec, expected_len); // Empty let empty = Assertion::is_empty(&vec); let not_empty = Assertion::is_not_empty(&vec); // All match predicate let all = Assertion::all_match(&vec, |x| x > 0); // Any match predicate let any = Assertion::any_match(&vec, |x| x == 42); }
String Assertions
#![allow(unused)] fn main() { // Contains substring let contains = Assertion::string_contains(&text, "expected"); // Starts/ends with let starts = Assertion::starts_with(&text, "prefix"); let ends = Assertion::ends_with(&text, "suffix"); // Regex match let matches = Assertion::matches_regex(&text, r"\d{3}-\d{4}"); // Length let len = Assertion::string_length(&text, expected_len); }
Option/Result Assertions
#![allow(unused)] fn main() { // Option let some = Assertion::is_some(&option_value); let none = Assertion::is_none(&option_value); // Result let ok = Assertion::is_ok(&result); let err = Assertion::is_err(&result); }
Custom Assertions
#![allow(unused)] fn main() { // Create custom assertion fn assert_valid_score(score: u32) -> Assertion { Assertion::custom( score <= 10, format!("Score {} should be <= 10", score), ) } // Use it let assertion = assert_valid_score(game.score); assert!(assertion.passed); }
Assertion Result
All assertions return an Assertion struct:
#![allow(unused)] fn main() { pub struct Assertion { pub passed: bool, pub message: String, pub expected: Option<String>, pub actual: Option<String>, } }
Combining Assertions
#![allow(unused)] fn main() { // All must pass let all_pass = Assertion::all(&[ Assertion::in_range(x, 0.0, 800.0), Assertion::in_range(y, 0.0, 600.0), Assertion::greater_than(health, 0), ]); // Any must pass let any_pass = Assertion::any(&[ Assertion::equals(&state, &State::Running), Assertion::equals(&state, &State::Paused), ]); }
Game-Specific Assertions
#![allow(unused)] fn main() { // Entity exists let exists = Assertion::entity_exists(&world, entity_id); // Component value let has_component = Assertion::has_component::<Position>(&world, entity); // Position bounds let in_bounds = Assertion::position_in_bounds( position, Bounds::new(0.0, 0.0, 800.0, 600.0), ); // Collision occurred let collided = Assertion::entities_colliding(&world, entity_a, entity_b); }
Example Test
#![allow(unused)] fn main() { #[test] fn test_game_state_validity() { let mut platform = WebPlatform::new_for_test(WebConfig::default()); // Advance game for _ in 0..100 { platform.advance_frame(1.0 / 60.0); } let state = platform.get_game_state(); // Multiple assertions assert!(Assertion::in_range(state.ball.x, 0.0, 800.0).passed); assert!(Assertion::in_range(state.ball.y, 0.0, 600.0).passed); assert!(Assertion::in_range(state.paddle_left.y, 0.0, 600.0).passed); assert!(Assertion::in_range(state.paddle_right.y, 0.0, 600.0).passed); assert!(Assertion::lte(state.score_left, 10).passed); assert!(Assertion::lte(state.score_right, 10).passed); } }
Simulation
Probar provides deterministic game simulation for testing.
Basic Simulation
#![allow(unused)] fn main() { use jugar_probar::{run_simulation, SimulationConfig}; let config = SimulationConfig::new(seed, num_frames); let result = run_simulation(config, |frame| { // Return inputs for this frame vec![] // No inputs }); assert!(result.completed); println!("Final state hash: {}", result.state_hash); }
Simulation with Inputs
#![allow(unused)] fn main() { use jugar_probar::{run_simulation, SimulationConfig, InputEvent}; let config = SimulationConfig::new(42, 300); let result = run_simulation(config, |frame| { // Move paddle up for first 100 frames if frame < 100 { vec![InputEvent::key_held("KeyW")] } else { vec![] } }); }
Input Events
#![allow(unused)] fn main() { // Keyboard InputEvent::key_press("Space") // Just pressed InputEvent::key_held("KeyW") // Held down InputEvent::key_release("Escape") // Just released // Mouse InputEvent::mouse_move(400.0, 300.0) InputEvent::mouse_press(MouseButton::Left) InputEvent::mouse_release(MouseButton::Left) // Touch InputEvent::touch_start(0, 100.0, 200.0) // id, x, y InputEvent::touch_move(0, 150.0, 250.0) InputEvent::touch_end(0) }
Deterministic Replay
#![allow(unused)] fn main() { use jugar_probar::{run_simulation, run_replay, SimulationConfig}; // Record a simulation let config = SimulationConfig::new(42, 500); let recording = run_simulation(config, |frame| { vec![InputEvent::key_press("ArrowUp")] }); // Replay it let replay = run_replay(&recording); // Verify determinism assert!(replay.determinism_verified); assert_eq!(recording.state_hash, replay.state_hash); }
Simulation Config
#![allow(unused)] fn main() { pub struct SimulationConfig { pub seed: u64, // Random seed for reproducibility pub frames: u32, // Number of frames to simulate pub fixed_dt: f32, // Timestep (default: 1/60) pub max_time: f32, // Max real time (for timeout) } let config = SimulationConfig { seed: 12345, frames: 1000, fixed_dt: 1.0 / 60.0, max_time: 60.0, }; }
Simulation Result
#![allow(unused)] fn main() { pub struct SimulationResult { pub completed: bool, pub frames_run: u32, pub state_hash: u64, pub final_state: GameState, pub recording: Recording, pub events: Vec<GameEvent>, } }
Recording Format
#![allow(unused)] fn main() { pub struct Recording { pub seed: u64, pub frames: Vec<FrameInputs>, pub state_snapshots: Vec<StateSnapshot>, } pub struct FrameInputs { pub frame: u32, pub inputs: Vec<InputEvent>, } }
Invariant Checking
#![allow(unused)] fn main() { use jugar_probar::{run_simulation_with_invariants, Invariant}; let invariants = vec![ Invariant::new("ball_in_bounds", |state| { state.ball.x >= 0.0 && state.ball.x <= 800.0 && state.ball.y >= 0.0 && state.ball.y <= 600.0 }), Invariant::new("score_valid", |state| { state.score_left <= 10 && state.score_right <= 10 }), ]; let result = run_simulation_with_invariants(config, invariants, |_| vec![]); assert!(result.all_invariants_held); for violation in &result.violations { println!("Violation at frame {}: {}", violation.frame, violation.invariant); } }
Scenario Testing
#![allow(unused)] fn main() { #[test] fn test_game_scenarios() { let scenarios = vec![ ("player_wins", |f| if f < 500 { vec![key("KeyW")] } else { vec![] }), ("ai_wins", |_| vec![]), // No player input ("timeout", |_| vec![key("KeyP")]), // Pause ]; for (name, input_fn) in scenarios { let config = SimulationConfig::new(42, 1000); let result = run_simulation(config, input_fn); println!("Scenario '{}': score = {} - {}", name, result.final_state.score_left, result.final_state.score_right); } } }
Performance Benchmarking
#![allow(unused)] fn main() { use std::time::Instant; #[test] fn benchmark_simulation() { let config = SimulationConfig::new(42, 10000); let start = Instant::now(); let result = run_simulation(config, |_| vec![]); let elapsed = start.elapsed(); println!("10000 frames in {:?}", elapsed); println!("FPS: {}", 10000.0 / elapsed.as_secs_f64()); // Should run faster than real-time assert!(elapsed.as_secs_f64() < 10000.0 / 60.0); } }
Fuzzing
Probar includes fuzzing support for finding edge cases in game logic.
Random Walk Agent
#![allow(unused)] fn main() { use jugar_probar::{RandomWalkAgent, Seed}; let seed = Seed::from_u64(12345); let mut agent = RandomWalkAgent::new(seed); // Generate random inputs for each frame for frame in 0..1000 { let inputs = agent.next_inputs(); platform.process_inputs(&inputs); platform.advance_frame(1.0 / 60.0); } }
Fuzzing with Invariants
#![allow(unused)] fn main() { use jugar_probar::{fuzz_with_invariants, FuzzConfig, Invariant}; let invariants = vec![ Invariant::new("no_crashes", |state| state.is_valid()), Invariant::new("ball_visible", |state| { state.ball.x.is_finite() && state.ball.y.is_finite() }), Invariant::new("score_bounded", |state| { state.score_left <= 100 && state.score_right <= 100 }), ]; let config = FuzzConfig { iterations: 1000, frames_per_iteration: 500, seed: 42, }; let result = fuzz_with_invariants(config, invariants); if !result.all_passed { for failure in &result.failures { println!("Invariant '{}' failed at iteration {} frame {}", failure.invariant_name, failure.iteration, failure.frame); println!("Reproducing seed: {}", failure.seed); } } }
Input Generation Strategies
#![allow(unused)] fn main() { // Random inputs let mut agent = RandomWalkAgent::new(seed); // Biased toward movement let mut agent = RandomWalkAgent::new(seed) .with_key_probability("KeyW", 0.3) .with_key_probability("KeyS", 0.3) .with_key_probability("Space", 0.1); // Chaos monkey (random everything) let mut agent = ChaosAgent::new(seed); // Adversarial (try to break the game) let mut agent = AdversarialAgent::new(seed) .target_invariant(|state| state.ball.y >= 0.0); }
Property-Based Testing
#![allow(unused)] fn main() { use proptest::prelude::*; proptest! { #[test] fn ball_stays_in_bounds(seed in 0u64..10000) { let config = SimulationConfig::new(seed, 1000); let result = run_simulation(config, |_| vec![]); prop_assert!(result.final_state.ball.x >= 0.0); prop_assert!(result.final_state.ball.x <= 800.0); prop_assert!(result.final_state.ball.y >= 0.0); prop_assert!(result.final_state.ball.y <= 600.0); } #[test] fn score_is_valid( seed in 0u64..10000, frames in 100u32..5000 ) { let config = SimulationConfig::new(seed, frames); let result = run_simulation(config, |_| vec![]); prop_assert!(result.final_state.score_left <= 10); prop_assert!(result.final_state.score_right <= 10); } } }
Seed Management
#![allow(unused)] fn main() { use jugar_probar::Seed; // From u64 let seed = Seed::from_u64(42); // From bytes let seed = Seed::from_bytes(&[1, 2, 3, 4, 5, 6, 7, 8]); // Random let seed = Seed::random(); // Get value for reproduction println!("Failing seed: {}", seed.as_u64()); }
Reproducing Failures
When fuzzing finds a failure, reproduce it:
#![allow(unused)] fn main() { #[test] fn reproduce_bug_12345() { // Seed from fuzzing failure let seed = Seed::from_u64(12345); let config = SimulationConfig::new(seed.as_u64(), 500); let result = run_simulation(config, |_| vec![]); // This should fail with the original bug assert!(result.final_state.ball.y >= 0.0); } }
Fuzzing Configuration
#![allow(unused)] fn main() { pub struct FuzzConfig { pub iterations: u32, // Number of random runs pub frames_per_iteration: u32, // Frames per run pub seed: u64, // Base seed pub timeout_seconds: u32, // Max time per iteration pub parallel: bool, // Run in parallel pub save_failures: bool, // Save failing cases } let config = FuzzConfig { iterations: 10000, frames_per_iteration: 1000, seed: 42, timeout_seconds: 10, parallel: true, save_failures: true, }; }
Shrinking
When a failure is found, Probar automatically shrinks the input:
#![allow(unused)] fn main() { let result = fuzz_with_shrinking(config, invariants); if let Some(failure) = result.first_failure { println!("Original failure at frame {}", failure.original_frame); println!("Shrunk to frame {}", failure.shrunk_frame); println!("Minimal inputs: {:?}", failure.minimal_inputs); } }
Continuous Fuzzing
Run fuzzing in CI:
# Run fuzzing for 10 minutes
FUZZ_DURATION=600 cargo test fuzz_ -- --ignored
# Or via make
make fuzz-ci
Locators
Probar provides Playwright-style locators for finding game elements.
Basic Locators
#![allow(unused)] fn main() { use jugar_probar::locator::*; // By ID let player = Locator::id("player"); // By name let score = Locator::name("score_display"); // By component type let balls = Locator::component::<Ball>(); // By tag let enemies = Locator::tag("enemy"); }
Entity Queries
#![allow(unused)] fn main() { let platform = WebPlatform::new_for_test(config); // Find single entity let player = platform.locate(Locator::id("player"))?; let pos = platform.get_position(player); // Find all matching let coins: Vec<Entity> = platform.locate_all(Locator::tag("coin")); assert_eq!(coins.len(), 5); // First matching let first_enemy = platform.locate_first(Locator::tag("enemy")); }
Compound Locators
#![allow(unused)] fn main() { // AND - must match all let armed_enemy = Locator::tag("enemy") .and(Locator::has_component::<Weapon>()); // OR - match any let interactable = Locator::tag("door") .or(Locator::tag("chest")); // NOT - exclude let non_player = Locator::component::<Character>() .not(Locator::id("player")); }
Spatial Locators
#![allow(unused)] fn main() { // Within radius let nearby = Locator::within_radius(player_pos, 100.0); // In bounds let visible = Locator::in_bounds(screen_bounds); // Nearest to point let closest_enemy = Locator::nearest_to(player_pos) .with_filter(Locator::tag("enemy")); }
Component-Based Locators
#![allow(unused)] fn main() { // Has specific component let physics_entities = Locator::has_component::<RigidBody>(); // Component matches predicate let low_health = Locator::component_matches::<Health>(|h| h.value < 20); // Has all components let complete_entities = Locator::has_all_components::<( Position, Velocity, Sprite, )>(); }
Type-Safe Locators (with derive)
Using jugar-probar-derive for compile-time checked selectors:
#![allow(unused)] fn main() { use jugar_probar_derive::Entity; #[derive(Entity)] #[entity(id = "player")] struct Player; #[derive(Entity)] #[entity(tag = "enemy")] struct Enemy; // Compile-time verified let player = platform.locate::<Player>()?; let enemies = platform.locate_all::<Enemy>(); }
Waiting for Elements
#![allow(unused)] fn main() { // Wait for entity to exist let boss = platform.wait_for( Locator::id("boss"), Duration::from_secs(5), )?; // Wait for condition platform.wait_until( || platform.locate(Locator::id("door")).is_some(), Duration::from_secs(2), )?; }
Locator Chains
#![allow(unused)] fn main() { // Find children let player_weapon = Locator::id("player") .child(Locator::tag("weapon")); // Find parent let weapon_owner = Locator::id("sword") .parent(); // Find siblings let adjacent_tiles = Locator::id("current_tile") .siblings(); }
Actions on Located Elements
#![allow(unused)] fn main() { let button = platform.locate(Locator::id("start_button"))?; // Get info let pos = platform.get_position(button); let bounds = platform.get_bounds(button); let visible = platform.is_visible(button); // Interact platform.click(button); platform.hover(button); // Check state let enabled = platform.is_enabled(button); let focused = platform.is_focused(button); }
Example Test
#![allow(unused)] fn main() { #[test] fn test_coin_collection() { let mut platform = WebPlatform::new_for_test(config); // Count initial coins let initial_coins = platform.locate_all(Locator::tag("coin")).len(); assert_eq!(initial_coins, 5); // Move player to first coin let first_coin = platform.locate_first(Locator::tag("coin")).unwrap(); let coin_pos = platform.get_position(first_coin); // Simulate movement move_player_to(&mut platform, coin_pos); // Coin should be collected let remaining_coins = platform.locate_all(Locator::tag("coin")).len(); assert_eq!(remaining_coins, 4); // Score should increase let score_display = platform.locate(Locator::id("score")).unwrap(); let score_text = platform.get_text(score_display); assert!(score_text.contains("10")); } }
Coverage Tooling
Probar includes advanced coverage instrumentation for WASM games.
Overview
Traditional coverage tools (LLVM, gcov) don't work well with WASM. Probar implements a renderfarm-inspired block coverage model where:
- WASM code is decomposed into coverage blocks (like render buckets)
- Blocks are independently testable and falsifiable
- Coverage aggregation uses SIMD-accelerated operations via Trueno
Basic Coverage
#![allow(unused)] fn main() { use jugar_probar::coverage::*; // Enable coverage collection let mut coverage = CoverageCollector::new(); // Run tests with coverage coverage.start(); run_tests(); let report = coverage.finish(); // Print summary println!("Line coverage: {:.1}%", report.line_coverage * 100.0); println!("Branch coverage: {:.1}%", report.branch_coverage * 100.0); println!("Function coverage: {:.1}%", report.function_coverage * 100.0); }
Block-Based Coverage
#![allow(unused)] fn main() { use jugar_probar::coverage::{BlockId, FunctionId, EdgeId}; // Type-safe identifiers (Poka-Yoke) let block = BlockId::new(42); let function = FunctionId::new(1); // EdgeId encodes source and target let edge = EdgeId::new(BlockId::new(10), BlockId::new(20)); assert_eq!(edge.source().as_u32(), 10); assert_eq!(edge.target().as_u32(), 20); }
Thread-Local Buffering (Muda Elimination)
#![allow(unused)] fn main() { use jugar_probar::coverage::ThreadLocalCounters; // Traditional: Atomic increment on every hit (contention) // Probar: Thread-local buffering, batch flush let counters = ThreadLocalCounters::new(1000); // 1000 blocks // Fast local increment counters.hit(BlockId::new(42)); // Periodic flush to global counters.flush(); }
Running Coverage
Via Makefile
# Full coverage report
make coverage
# E2E coverage
make test-e2e-coverage
# Quick summary
make coverage-summary
# Open HTML report
make coverage-open
Via Cargo
# Generate report
cargo llvm-cov --html --output-dir target/coverage
# Summary only
cargo llvm-cov report --summary-only
# With nextest
cargo llvm-cov nextest --workspace
Coverage Targets
| Metric | Minimum | Target |
|---|---|---|
| Line Coverage | 85% | 95% |
| Branch Coverage | 75% | 90% |
| Function Coverage | 90% | 100% |
| Mutation Score | 80% | 90% |
Coverage Report
#![allow(unused)] fn main() { pub struct CoverageReport { pub line_coverage: f64, pub branch_coverage: f64, pub function_coverage: f64, pub covered_lines: u32, pub total_lines: u32, pub covered_branches: u32, pub total_branches: u32, pub covered_functions: u32, pub total_functions: u32, pub uncovered_lines: Vec<LineInfo>, } }
Superblock Scheduling
For parallel coverage analysis:
#![allow(unused)] fn main() { use jugar_probar::coverage::{Superblock, Scheduler}; // Group blocks into superblocks let superblocks = Scheduler::create_superblocks(&blocks, num_workers); // Execute in parallel let results = superblocks .par_iter() .map(|sb| execute_superblock(sb)) .collect(); // Merge results let final_coverage = CoverageMerger::merge(&results); }
Soft Jidoka (Error Classification)
#![allow(unused)] fn main() { use jugar_probar::coverage::{Error, Severity}; // Distinguish fatal vs recoverable errors match error.severity { Severity::Fatal => { // Stop immediately (Andon cord) panic!("Fatal error in coverage: {}", error); } Severity::Recoverable => { // Log and continue log::warn!("Recoverable error: {}", error); continue; } Severity::Ignorable => { // Skip silently } } }
Coverage Example
cargo run --example coverage_demo -p jugar-probar
Output:
=== Probar Coverage Demo ===
--- Type-Safe Identifiers (Poka-Yoke) ---
BlockId(42) - Type-safe block identifier
FunctionId(1) - Type-safe function identifier
EdgeId(10 -> 20) - Encodes source and target
--- Thread-Local Counters (Muda Elimination) ---
Created counters for 1000 blocks
Hit block 42: 1000 times
Hit block 99: 500 times
After flush: block 42 = 1000, block 99 = 500
--- Superblock Scheduling ---
4 workers, 16 superblocks
Superblock 0: blocks [0..62]
Superblock 1: blocks [63..125]
...
✅ Coverage demo complete!
Integration with CI
- name: Generate coverage
run: |
cargo llvm-cov --lcov --output-path lcov.info
cargo llvm-cov report --summary-only
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: lcov.info
Deterministic Replay
Probar enables frame-perfect replay of game sessions.
Why Deterministic Replay?
- Bug Reproduction: Replay exact sequence that caused a bug
- Regression Testing: Verify behavior matches after changes
- Test Generation: Record gameplay, convert to tests
- Demo Playback: Record and replay gameplay sequences
Recording a Session
#![allow(unused)] fn main() { use jugar_probar::{Recorder, Recording}; let mut recorder = Recorder::new(seed); let mut platform = WebPlatform::new_for_test(config); // Play game and record for frame in 0..1000 { let inputs = get_user_inputs(); recorder.record_frame(frame, &inputs); platform.process_inputs(&inputs); platform.advance_frame(1.0 / 60.0); } // Get recording let recording = recorder.finish(); // Save to file recording.save("gameplay.replay")?; }
Replaying a Session
#![allow(unused)] fn main() { use jugar_probar::{Recording, Replayer}; // Load recording let recording = Recording::load("gameplay.replay")?; let mut replayer = Replayer::new(&recording); let mut platform = WebPlatform::new_for_test(config); // Replay exactly while let Some(inputs) = replayer.next_frame() { platform.process_inputs(&inputs); platform.advance_frame(1.0 / 60.0); } // Verify final state matches assert_eq!( replayer.expected_final_hash(), platform.state_hash() ); }
Recording Format
#![allow(unused)] fn main() { pub struct Recording { pub version: u32, pub seed: u64, pub config: GameConfig, pub frames: Vec<FrameData>, pub final_state_hash: u64, } pub struct FrameData { pub frame_number: u32, pub inputs: Vec<InputEvent>, pub state_hash: Option<u64>, // Optional checkpoints } }
State Hashing
#![allow(unused)] fn main() { // Hash game state for comparison let hash = platform.state_hash(); // Or hash specific components let ball_hash = hash_state(&platform.get_ball_state()); let score_hash = hash_state(&platform.get_score()); }
Verification
#![allow(unused)] fn main() { use jugar_probar::{verify_replay, ReplayVerification}; let result = verify_replay(&recording); match result { ReplayVerification::Perfect => { println!("Replay is deterministic!"); } ReplayVerification::Diverged { frame, expected, actual } => { println!("Diverged at frame {}", frame); println!("Expected hash: {}", expected); println!("Actual hash: {}", actual); } ReplayVerification::Failed(error) => { println!("Replay failed: {}", error); } } }
Checkpoints
Add checkpoints for faster debugging:
#![allow(unused)] fn main() { let mut recorder = Recorder::new(seed) .with_checkpoint_interval(60); // Every 60 frames // Or manual checkpoints recorder.add_checkpoint(platform.snapshot()); }
Binary Replay Format
#![allow(unused)] fn main() { // Compact binary format for storage let bytes = recording.to_bytes(); let recording = Recording::from_bytes(&bytes)?; // Compressed let compressed = recording.to_compressed_bytes(); let recording = Recording::from_compressed_bytes(&compressed)?; }
Replay Speed Control
#![allow(unused)] fn main() { let mut replayer = Replayer::new(&recording); // Normal speed replayer.set_speed(1.0); // Fast forward replayer.set_speed(4.0); // Slow motion replayer.set_speed(0.25); // Step by step replayer.step(); // Advance one frame }
Example: Test from Replay
#![allow(unused)] fn main() { #[test] fn test_from_recorded_gameplay() { let recording = Recording::load("tests/fixtures/win_game.replay").unwrap(); let mut replayer = Replayer::new(&recording); let mut platform = WebPlatform::new_for_test(recording.config.clone()); // Replay all frames while let Some(inputs) = replayer.next_frame() { platform.process_inputs(&inputs); platform.advance_frame(1.0 / 60.0); } // Verify end state let state = platform.get_game_state(); assert_eq!(state.winner, Some(Player::Left)); assert_eq!(state.score_left, 10); } }
CI Integration
# Verify all replay files are still deterministic
cargo test replay_verification -- --include-ignored
# Or via make
make verify-replays
Debugging with Replays
#![allow(unused)] fn main() { // Find frame where bug occurs let bug_frame = binary_search_replay(&recording, |state| { state.ball.y < 0.0 // Bug condition }); println!("Bug first occurs at frame {}", bug_frame); // Get inputs leading up to bug let inputs = recording.frames[..bug_frame].to_vec(); println!("Inputs: {:?}", inputs); }
Accessibility Testing
Probar includes WCAG accessibility checking for games.
Overview
Probar validates accessibility requirements:
- Color contrast ratios (WCAG 2.1)
- Photosensitivity (flashing content)
- Text readability
- Input alternatives
Color Contrast
#![allow(unused)] fn main() { use jugar_probar::accessibility::*; // Check contrast ratio let ratio = contrast_ratio(foreground_color, background_color); assert!(ratio >= 4.5); // WCAG AA for normal text assert!(ratio >= 7.0); // WCAG AAA for normal text assert!(ratio >= 3.0); // WCAG AA for large text // Automatic checking let result = check_text_contrast(&platform); assert!(result.passes_aa); }
Contrast Levels
| Level | Normal Text | Large Text |
|---|---|---|
| AA | 4.5:1 | 3:1 |
| AAA | 7:1 | 4.5:1 |
Photosensitivity
#![allow(unused)] fn main() { use jugar_probar::accessibility::*; // Check for problematic flashing let mut checker = FlashChecker::new(); for frame in 0..180 { // 3 seconds at 60fps let screenshot = platform.capture_frame(); checker.add_frame(&screenshot); } let result = checker.analyze(); assert!(result.safe_for_photosensitive); // WCAG 2.3.1: No more than 3 flashes per second assert!(result.max_flashes_per_second <= 3.0); }
Color Blindness Simulation
#![allow(unused)] fn main() { use jugar_probar::accessibility::*; // Simulate different types let normal = platform.capture_frame(); let protanopia = simulate_color_blindness(&normal, ColorBlindType::Protanopia); let deuteranopia = simulate_color_blindness(&normal, ColorBlindType::Deuteranopia); let tritanopia = simulate_color_blindness(&normal, ColorBlindType::Tritanopia); // Check important elements are still distinguishable assert!(elements_distinguishable(&protanopia, "player", "enemy")); }
Text Accessibility
#![allow(unused)] fn main() { use jugar_probar::accessibility::*; // Check text size let text_elements = platform.locate_all(Locator::component::<Text>()); for text in text_elements { let size = platform.get_font_size(text); assert!(size >= 12.0, "Text too small: {}", size); // Check contrast let fg = platform.get_text_color(text); let bg = platform.get_background_color(text); let ratio = contrast_ratio(fg, bg); assert!(ratio >= 4.5, "Insufficient contrast: {}", ratio); } }
Input Alternatives
#![allow(unused)] fn main() { use jugar_probar::accessibility::*; // Verify all actions have keyboard alternatives let result = check_keyboard_accessibility(&platform); assert!(result.all_actions_keyboard_accessible); // List any mouse-only actions for action in &result.mouse_only_actions { println!("Missing keyboard alternative: {}", action); } }
Running Accessibility Tests
# Run accessibility demo
cargo run --example accessibility_demo -p jugar-probar
# Run accessibility tests
cargo test -p jugar-web accessibility_
Accessibility Report
#![allow(unused)] fn main() { pub struct AccessibilityReport { pub passes_wcag_aa: bool, pub passes_wcag_aaa: bool, pub contrast_issues: Vec<ContrastIssue>, pub flash_warnings: Vec<FlashWarning>, pub keyboard_issues: Vec<KeyboardIssue>, pub overall_score: f32, // 0.0 - 100.0 } }
Example Test
#![allow(unused)] fn main() { #[test] fn test_game_accessibility() { let mut platform = WebPlatform::new_for_test(config); // Run a few frames for _ in 0..60 { platform.advance_frame(1.0 / 60.0); } // Check accessibility let report = check_accessibility(&platform); // Must pass WCAG AA assert!(report.passes_wcag_aa, "WCAG AA failures: {:?}", report.contrast_issues); // No flash warnings assert!(report.flash_warnings.is_empty(), "Flash warnings: {:?}", report.flash_warnings); // Score should be high assert!(report.overall_score >= 80.0, "Accessibility score too low: {}", report.overall_score); } }
Continuous Monitoring
#![allow(unused)] fn main() { use jugar_probar::accessibility::*; // Monitor throughout gameplay let mut monitor = AccessibilityMonitor::new(); for frame in 0..6000 { // 100 seconds platform.advance_frame(1.0 / 60.0); // Check each frame monitor.check_frame(&platform); } let report = monitor.finish(); println!("Accessibility issues found: {}", report.issues.len()); }
Configuration
#![allow(unused)] fn main() { pub struct AccessibilityConfig { pub min_contrast_ratio: f32, // Default: 4.5 (AA) pub min_text_size: f32, // Default: 12.0 pub max_flashes_per_second: f32, // Default: 3.0 pub require_keyboard_nav: bool, // Default: true } let config = AccessibilityConfig { min_contrast_ratio: 7.0, // AAA level ..Default::default() }; let report = check_accessibility_with_config(&platform, &config); }
Universal Pong
A responsive Pong implementation that works from mobile to 32:9 ultrawide.
Features
- Touch controls (mobile)
- Keyboard (W/S, Up/Down)
- Gamepad support
- Responsive paddle positioning
- AI opponent with Dynamic Difficulty Adjustment
- SHAP-like explainability widgets
Running
# Build WASM
make build-web
# Serve locally
make serve-web
# Open http://localhost:8080
Controls
| Input | Player 1 | Player 2 |
|---|---|---|
| Keyboard | W/S | Up/Down |
| Touch | Left side | Right side |
| Gamepad | Left stick | Right stick |
Architecture
Game State
#![allow(unused)] fn main() { pub struct PongState { pub ball: Ball, pub paddle_left: Paddle, pub paddle_right: Paddle, pub score_left: u32, pub score_right: u32, pub game_mode: GameMode, } }
Ball Physics
#![allow(unused)] fn main() { impl Ball { pub fn update(&mut self, dt: f32) { self.position += self.velocity * dt; // Wall bounce if self.position.y <= 0.0 || self.position.y >= 600.0 { self.velocity.y = -self.velocity.y; } } pub fn check_paddle_collision(&mut self, paddle: &Paddle) { if self.bounds().intersects(paddle.bounds()) { self.velocity.x = -self.velocity.x; // Add spin based on hit position let offset = (self.position.y - paddle.position.y) / paddle.height; self.velocity.y += offset * 200.0; } } } }
AI Opponent
The AI uses a trained model (.apr format) with Dynamic Difficulty Adjustment:
#![allow(unused)] fn main() { pub struct PongAI { model: AprModel, difficulty: f32, // 0.0 - 1.0 } impl PongAI { pub fn update(&mut self, state: &PongState) -> f32 { // Predict optimal position let optimal = self.model.predict(state); // Add error based on difficulty let error = (1.0 - self.difficulty) * random_offset(); optimal + error } } }
Responsive Design
Viewport Scaling
#![allow(unused)] fn main() { // Safe area for 16:9 gameplay let safe = viewport.safe_area(); // Extended for ultrawide let extended = viewport.extended_area(); // Position paddles at edges let left_paddle_x = safe.left + PADDLE_MARGIN; let right_paddle_x = safe.right - PADDLE_MARGIN; }
Touch Zones
#![allow(unused)] fn main() { // Left half: Player 1 // Right half: Player 2 fn get_touch_player(touch_x: f32, screen_width: f32) -> Player { if touch_x < screen_width / 2.0 { Player::Left } else { Player::Right } } }
Testing
# Run Probar E2E tests
make test-e2e
# Run with verbose output
make test-e2e-verbose
Test Suites
| Suite | Tests | Coverage |
|---|---|---|
| Core Functionality | 6 | WASM loading, rendering |
| Demo Features | 22 | Game modes, HUD, AI |
| Release Readiness | 11 | Stress tests, edge cases |
Explainability Widgets
The AI's decision-making is visualized:
#![allow(unused)] fn main() { pub struct ShapWidget { features: Vec<FeatureContribution>, } impl ShapWidget { pub fn render(&self) -> Vec<RenderCommand> { // Show feature contributions as bars self.features.iter().map(|f| { RenderCommand::DrawRect { width: f.contribution.abs() * 100.0, color: if f.contribution > 0.0 { GREEN } else { RED }, // ... } }).collect() } } }
Source Code
crates/jugar-web/src/demo.rs- Pong demo implementationcrates/jugar-web/src/ai.rs- AI opponentexamples/pong-web/index.html- HTML loader
Physics Toy Sandbox
A remixable Rube Goldberg physics builder.
Overview
The Physics Toy Sandbox is a creative physics playground where users can:
- Build contraptions from various materials
- Watch physics simulations run
- Remix and share designs
- Learn physics concepts through play
Running
# Build
make sandbox
# Test
make test-sandbox
# Build WASM
make build-sandbox-wasm
Concepts
Materials
#![allow(unused)] fn main() { pub enum Material { Wood, Metal, Rubber, Glass, Stone, } impl Material { pub fn density(&self) -> f32 { match self { Material::Wood => 0.6, Material::Metal => 7.8, Material::Rubber => 1.1, Material::Glass => 2.5, Material::Stone => 2.4, } } pub fn restitution(&self) -> f32 { match self { Material::Wood => 0.3, Material::Metal => 0.2, Material::Rubber => 0.9, Material::Glass => 0.1, Material::Stone => 0.1, } } } }
Contraptions
#![allow(unused)] fn main() { pub struct Contraption { pub parts: Vec<Part>, pub joints: Vec<Joint>, pub version: EngineVersion, } pub struct Part { pub shape: Shape, pub material: Material, pub position: Vec2, pub rotation: f32, } pub enum Joint { Hinge { a: PartId, b: PartId, anchor: Vec2 }, Spring { a: PartId, b: PartId, stiffness: f32 }, Rope { a: PartId, b: PartId, length: f32 }, } }
Complexity Thermometer
Visual control for contraption complexity (Mieruka principle):
#![allow(unused)] fn main() { pub struct ComplexityThermometer { part_count: u32, joint_count: u32, physics_budget: f32, } impl ComplexityThermometer { pub fn score(&self) -> f32 { let parts = self.part_count as f32 * 1.0; let joints = self.joint_count as f32 * 2.0; (parts + joints) / self.physics_budget } pub fn color(&self) -> Color { let score = self.score(); if score < 0.5 { Color::GREEN } else if score < 0.8 { Color::YELLOW } else { Color::RED } } } }
Remix System
Contraptions can be remixed and shared:
#![allow(unused)] fn main() { pub struct RemixMetadata { pub original_author: String, pub original_version: String, pub remix_chain: Vec<RemixInfo>, } impl Contraption { pub fn remix(&self, author: &str) -> Contraption { Contraption { parts: self.parts.clone(), joints: self.joints.clone(), version: EngineVersion::current(), metadata: RemixMetadata { remix_chain: { let mut chain = self.metadata.remix_chain.clone(); chain.push(RemixInfo { author: author.to_string(), timestamp: now(), }); chain }, ..self.metadata.clone() }, } } } }
Engine Versioning
Replay compatibility across versions (Jidoka principle):
#![allow(unused)] fn main() { pub struct EngineVersion { pub major: u32, pub minor: u32, pub patch: u32, } impl EngineVersion { pub fn is_compatible(&self, other: &EngineVersion) -> bool { self.major == other.major } pub fn current() -> Self { EngineVersion { major: 1, minor: 0, patch: 0 } } } }
Toyota Way Principles
| Principle | Application |
|---|---|
| Poka-Yoke | NonZeroU32 for density (no division by zero) |
| Jidoka | Engine versioning for replay compatibility |
| Mieruka | ComplexityThermometer for visual control |
| Muda | No scalar fallback (WebGPU/WASM SIMD only) |
Testing
# All tests
make test-sandbox
# With coverage
make test-sandbox-coverage
# Mutation testing
make sandbox-mutate
Source Code
crates/physics-toy-sandbox/src/lib.rs- Main librarycrates/physics-toy-sandbox/src/material.rs- Material systemcrates/physics-toy-sandbox/src/contraption.rs- Contraption buildingcrates/physics-toy-sandbox/src/remix.rs- Remix system
Running Examples
Available Examples
Probar Testing Examples
# Deterministic simulation with replay
cargo run --example pong_simulation -p jugar-probar
# Playwright-style locator API demo
cargo run --example locator_demo -p jugar-probar
# WCAG accessibility checking
cargo run --example accessibility_demo -p jugar-probar
# Coverage instrumentation demo
cargo run --example coverage_demo -p jugar-probar
Web Examples
# Build and serve Pong
make build-web
make serve-web
# Open http://localhost:8080
Physics Sandbox
# Build and test
make sandbox
# Build for WASM
make build-sandbox-wasm
Example Output
Pong Simulation
=== Probar Pong Simulation Demo ===
--- Demo 1: Pong Simulation ---
Initial state:
Ball: (400.0, 300.0)
Paddles: P1=300.0, P2=300.0
Score: 0 - 0
Simulating 300 frames...
Final state after 300 frames:
Ball: (234.5, 412.3)
Paddles: P1=180.0, P2=398.2
Score: 2 - 1
State valid: true
--- Demo 2: Deterministic Replay ---
Recording simulation (seed=42, frames=500)...
Completed: true
Final hash: 6233835744931225727
Replaying simulation...
Determinism verified: true
Hashes match: true
--- Demo 3: Invariant Fuzzing ---
Running 100 fuzz iterations...
All invariants held across all iterations
Invariants checked:
- ball_in_bounds: 100/100 passed
- score_valid: 100/100 passed
- paddle_in_bounds: 100/100 passed
Locator Demo
=== Probar Locator Demo ===
--- Basic Locators ---
Locator::id("player") -> Found entity #42
Locator::tag("enemy") -> Found 5 entities
Locator::component::<Ball>() -> Found 1 entity
--- Compound Locators ---
Locator::tag("enemy").and(has_component::<Weapon>()) -> Found 2 entities
Locator::within_radius(player_pos, 100.0) -> Found 3 entities
--- Type-Safe Locators ---
@[derive(Entity)] Player -> Found at (400, 300)
@[derive(Entity)] Enemy -> Found 5 instances
Accessibility Demo
=== Probar Accessibility Demo ===
--- Color Contrast ---
Text "Score: 0" - Foreground: #FFFFFF, Background: #1A1A2E
Contrast ratio: 12.5:1 (WCAG AAA: PASS)
Button "Start" - Foreground: #000000, Background: #4CAF50
Contrast ratio: 6.2:1 (WCAG AA: PASS)
--- Photosensitivity ---
Analyzed 180 frames (3 seconds at 60fps)
Max flashes per second: 0.3
Safe for photosensitive users: YES
--- Color Blindness ---
Protanopia simulation: All important elements distinguishable
Deuteranopia simulation: All important elements distinguishable
✅ Accessibility check passed!
Creating Your Own Examples
Project Structure
my-jugar-game/
├── Cargo.toml
├── src/
│ └── main.rs
└── examples/
└── demo.rs
Cargo.toml
[package]
name = "my-jugar-game"
version = "0.1.0"
edition = "2021"
[dependencies]
jugar = "0.1"
[dev-dependencies]
jugar-probar = "0.1"
[[example]]
name = "demo"
path = "examples/demo.rs"
Example Code
// examples/demo.rs use jugar::prelude::*; fn main() { let config = JugarConfig::default(); let mut engine = JugarEngine::new(config); println!("=== My Jugar Demo ==="); // Demo your game features let player = engine.world_mut().spawn(); engine.world_mut().add_component(player, Position::new(100.0, 100.0)); println!("Spawned player at (100, 100)"); // Run a few frames for i in 0..10 { engine.update(1.0 / 60.0); println!("Frame {}", i); } println!("Demo complete!"); }
Run Your Example
cargo run --example demo
API Documentation
Online Documentation
The complete API documentation is available at:
Generating Locally
cargo doc --open --no-deps
Main Types
JugarEngine
#![allow(unused)] fn main() { pub struct JugarEngine { // Fields are private } impl JugarEngine { pub fn new(config: JugarConfig) -> Self; pub fn from_definition(def: GameDefinition) -> Self; pub fn world(&self) -> &World; pub fn world_mut(&mut self) -> &mut World; pub fn run<F>(&mut self, game_loop: F) where F: FnMut(&mut GameContext) -> LoopControl; pub fn update(&mut self, dt: f32); } }
JugarConfig
#![allow(unused)] fn main() { pub struct JugarConfig { pub width: u32, pub height: u32, pub title: String, pub fixed_dt: f32, pub debug_overlay: bool, pub vsync: bool, } impl Default for JugarConfig { fn default() -> Self { JugarConfig { width: 1920, height: 1080, title: "Jugar Game".to_string(), fixed_dt: 1.0 / 60.0, debug_overlay: cfg!(debug_assertions), vsync: true, } } } }
World (ECS)
#![allow(unused)] fn main() { pub struct World { /* ... */ } impl World { pub fn new() -> Self; // Entities pub fn spawn(&mut self) -> Entity; pub fn despawn(&mut self, entity: Entity); pub fn is_alive(&self, entity: Entity) -> bool; // Components pub fn add_component<T: Component>(&mut self, entity: Entity, component: T); pub fn remove_component<T: Component>(&mut self, entity: Entity); pub fn get_component<T: Component>(&self, entity: Entity) -> Option<&T>; pub fn get_component_mut<T: Component>(&mut self, entity: Entity) -> Option<&mut T>; // Queries pub fn query<Q: Query>(&self) -> QueryIter<Q>; // Resources pub fn add_resource<T: Resource>(&mut self, resource: T); pub fn get_resource<T: Resource>(&self) -> Option<&T>; pub fn get_resource_mut<T: Resource>(&mut self) -> Option<&mut T>; } }
Components
#![allow(unused)] fn main() { // Position pub struct Position { pub x: f32, pub y: f32, } // Velocity pub struct Velocity { pub x: f32, pub y: f32, } // Transform pub struct Transform { pub position: Vec2, pub rotation: f32, pub scale: Vec2, } }
Input
#![allow(unused)] fn main() { pub struct InputState { /* ... */ } impl InputState { // Keyboard pub fn key_pressed(&self, key: KeyCode) -> bool; pub fn key_held(&self, key: KeyCode) -> bool; pub fn key_released(&self, key: KeyCode) -> bool; // Mouse pub fn mouse_position(&self) -> Vec2; pub fn mouse_pressed(&self, button: MouseButton) -> bool; pub fn mouse_held(&self, button: MouseButton) -> bool; pub fn scroll_delta(&self) -> Vec2; // Touch pub fn touches(&self) -> &[Touch]; // Gamepad pub fn gamepad(&self, id: usize) -> Option<&Gamepad>; } }
Crate-Specific APIs
jugar-physics
#![allow(unused)] fn main() { // PhysicsWorld pub struct PhysicsWorld { /* ... */ } impl PhysicsWorld { pub fn create_static_body(&mut self, pos: Position, collider: Collider) -> Body; pub fn create_dynamic_body(&mut self, pos: Position, collider: Collider, config: RigidBodyConfig) -> Body; pub fn apply_force(&mut self, body: Body, force: Vec2); pub fn apply_impulse(&mut self, body: Body, impulse: Vec2); pub fn raycast(&self, ray: Ray, max_distance: f32) -> Option<RayHit>; } }
jugar-ai
#![allow(unused)] fn main() { // Behavior Trees pub struct BehaviorTree { /* ... */ } impl BehaviorTree { pub fn new() -> BehaviorTreeBuilder; pub fn tick(&self, context: &mut Context) -> Status; } // GOAP pub struct GoapPlanner { /* ... */ } impl GoapPlanner { pub fn plan(&self, state: &WorldState, goal: &Goal, actions: &[Action]) -> Option<Vec<Action>>; } }
jugar-probar
#![allow(unused)] fn main() { // Assertions pub struct Assertion { pub passed: bool, pub message: String, } impl Assertion { pub fn equals<T: PartialEq>(a: &T, b: &T) -> Assertion; pub fn in_range(value: f32, min: f32, max: f32) -> Assertion; pub fn is_true(condition: bool) -> Assertion; // ... more } // Simulation pub fn run_simulation(config: SimulationConfig, input_fn: impl Fn(u32) -> Vec<InputEvent>) -> SimulationResult; pub fn run_replay(recording: &Recording) -> ReplayResult; }
Prelude
The prelude re-exports common types:
#![allow(unused)] fn main() { pub use crate::{ JugarEngine, JugarConfig, GameContext, LoopControl, World, Entity, Component, Position, Velocity, Transform, KeyCode, MouseButton, InputState, Vec2, Vec3, Mat4, Color, }; }
Import with:
#![allow(unused)] fn main() { use jugar::prelude::*; }
Quality Standards
Jugar follows strict quality standards based on PMAT methodology and Toyota Production System principles.
Quality Metrics
| Metric | Minimum | Target | Status |
|---|---|---|---|
| Test Coverage | 85% | 95% | ✅ |
| Mutation Score | 80% | 90% | ✅ |
| TDG Grade | B+ | A+ | ✅ |
| SATD Comments | 5 | 0 | ✅ |
| Unsafe Code | 0 | 0 | ✅ |
| JavaScript | 0 bytes | 0 bytes | ✅ |
Running Quality Checks
Tiered Workflow
# Tier 1: ON-SAVE (sub-second)
make tier1
# Tier 2: ON-COMMIT (1-5 minutes)
make tier2
# Tier 3: ON-MERGE (hours)
make tier3
Individual Checks
# Formatting
cargo fmt -- --check
# Linting
cargo clippy --all-targets --all-features -- -D warnings
# Tests
cargo test --all-features
# Coverage
make coverage
# Mutation testing
make mutate
PMAT Tools
# Technical Debt Grading
make pmat-tdg
# Repository score
make pmat-score
# Rust project score
make pmat-rust-score
# All PMAT checks
make pmat-all
Test Coverage
Target: 95%
# Generate coverage report
make coverage
# Open HTML report
make coverage-open
# CI mode (LCOV)
make coverage-ci
Excluded from Coverage
- Binary files (
bin/*.rs) - External crate internals (
wasmtime.*) - Proc-macro crates (
probar-derive) - Browser-only code (
browser.rs)
Mutation Testing
Target: 80% Kill Rate
# Full mutation testing
make mutate
# Quick (single module)
make mutate-quick
# Specific file
make mutate-file FILE=crates/jugar-web/src/juice.rs
# View report
make mutate-report
Technical Debt Grading
Target: B+ Minimum
The TDG system grades code quality:
| Grade | Description |
|---|---|
| A+ | Excellent - minimal debt |
| A | Very good |
| A- | Good |
| B+ | Acceptable |
| B | Needs improvement |
| C | Significant debt |
| D | Critical debt |
| F | Failing |
Continuous Improvement (Kaizen)
# Run kaizen analysis
make kaizen
# Outputs:
# - Code metrics (LOC, complexity)
# - Coverage analysis
# - Technical debt grade
# - Improvement recommendations
Pre-Commit Checks
Before committing, always run:
make tier2
This verifies:
- Zero JavaScript (policy)
- Batuta dependencies (policy)
- Formatting
- Linting
- All tests
- Property tests
- Coverage
- TDG grade
- SATD comments
CI/CD Pipeline
All PRs must pass:
- cargo fmt -- --check
- cargo clippy --all-targets -- -D warnings
- cargo test --all-features
- make verify-no-js
- make pmat-tdg
Quality Gate Failures
If quality gates fail:
- Coverage below 95%: Add missing tests
- Mutation score below 80%: Strengthen assertions
- TDG below B+: Address technical debt
- JavaScript detected: Remove and use Rust/WASM
- Clippy warnings: Fix all warnings
Tools Required
# Install all quality tools
make install-tools
# Includes:
# - cargo-llvm-cov (coverage)
# - cargo-mutants (mutation testing)
# - cargo-nextest (fast test runner)
# - cargo-audit (security)
# - pmat (quality metrics)
Makefile Targets
Complete reference for all Makefile targets.
Quick Reference
| Target | Description |
|---|---|
make tier1 | Sub-second feedback (ON-SAVE) |
make tier2 | Full validation (ON-COMMIT) |
make tier3 | Mutation testing (ON-MERGE) |
make build-web | Build WASM for web |
make test-e2e | Run Probar E2E tests |
make coverage | Generate coverage report |
Tiered Workflow
Tier 1: ON-SAVE (Sub-second)
make tier1
Runs:
- Type checking (
cargo check) - Fast clippy (library only)
- Unit tests
- Property tests (10 cases)
Tier 2: ON-COMMIT (1-5 minutes)
make tier2
Runs:
- Zero JavaScript verification
- Batuta dependency check
- Format check
- Full clippy
- All tests
- Property tests (256 cases)
- Coverage analysis
- TDG grade check
Tier 3: ON-MERGE (Hours)
make tier3
Runs:
- All Tier 2 checks
- Mutation testing (80% target)
- Security audit
- Benchmarks
- PMAT repo score
Build Targets
make build # Host target (dev)
make build-release # Host target (optimized)
make build-wasm # WASM target (release)
make build-wasm-dev # WASM target (debug)
make build-web # Build with wasm-pack
make serve-web # Serve locally (port 8080)
Test Targets
make test # All tests
make test-fast # Library tests only (<2 min)
make test-e2e # Probar E2E tests
make test-e2e-verbose # E2E with output
make test-property # Property tests (50 cases)
make test-property-full # Property tests (500 cases)
Quality Targets
make lint # Full clippy
make fmt # Format code
make fmt-check # Check formatting
Coverage Targets
make coverage # Generate HTML report
make coverage-summary # Show summary
make coverage-open # Open in browser
make coverage-check # Enforce 95% threshold
make coverage-ci # Generate LCOV for CI
make coverage-clean # Clean artifacts
PMAT Targets
make pmat-tdg # Technical Debt Grading
make pmat-analyze # Complexity, SATD, dead code
make pmat-score # Repository health score
make pmat-rust-score # Rust project score
make pmat-validate-docs # Documentation validation
make pmat-all # All PMAT checks
Mutation Testing
make mutate # jugar-web crate (<5 min)
make mutate-quick # Single module (<2 min)
make mutate-file FILE=path/to/file.rs
make mutate-report # View results
Load Testing
make load-test # All load tests
make load-test-quick # Quick chaos tests
make load-test-full # Full detailed tests
make ai-test # AI CLI tests
make ai-simulate # Run AI simulation
Verification
make verify-no-js # Zero JavaScript check
make verify-batuta-deps # Batuta dependencies
make verify-wasm-output # Pure WASM output
Kaizen
make kaizen # Continuous improvement analysis
Physics Sandbox
make sandbox # Build and test
make test-sandbox # Run tests
make test-sandbox-coverage # With coverage
make sandbox-lint # Pedantic clippy
make sandbox-mutate # Mutation testing
make build-sandbox-wasm # WASM build
Development
make dev # Watch mode
make install-tools # Install required tools
make clean # Clean build artifacts
make clean-pmat # Clean PMAT artifacts
Book
make book # Build mdbook
make book-serve # Serve locally
make book-open # Open in browser
Help
make help # Show all targets with descriptions
Contributing
We welcome contributions to Jugar!
Getting Started
Clone the Repository
git clone https://github.com/paiml/jugar.git
cd jugar
Install Tools
make install-tools
Run Tests
make tier2
Development Workflow
1. Create a Branch
git checkout -b feature/my-feature
2. Make Changes
Follow the coding standards below.
3. Run Checks
# Quick check during development
make tier1
# Full check before committing
make tier2
4. Commit
git add .
git commit -m "feat: add my feature"
5. Push and Create PR
git push origin feature/my-feature
# Create PR on GitHub
Coding Standards
Zero JavaScript
CRITICAL: No JavaScript in game logic.
- ❌ No
.jsor.tsfiles - ❌ No
npmorpackage.json - ❌ No JavaScript bundlers
- ✅ Pure Rust only
- ✅ WASM output only
Batuta-First
Use Batuta stack components before external crates:
truenofor SIMD/GPU computeaprenderfor ML/AItrueno-vizfor rendering
Formatting
cargo fmt
Linting
cargo clippy --all-targets -- -D warnings
No warnings allowed.
Tests
- Write tests for all new code
- Maintain 95%+ coverage
- Include property tests for complex logic
make coverage
Documentation
- Add doc comments to public APIs
- Update book if adding features
- Include examples for new functionality
Commit Messages
Follow conventional commits:
type(scope): description
[optional body]
[optional footer]
Types:
feat: New featurefix: Bug fixdocs: Documentationrefactor: Code refactoringtest: Adding testschore: Maintenance
Examples:
feat(physics): add continuous collision detection
fix(ai): correct pathfinding edge case
docs(book): add probar chapter
Pull Request Guidelines
Requirements
All PRs must:
- Pass CI (tier2 equivalent)
- Have tests for new code
- Update documentation if needed
- Have descriptive commit messages
Review Process
- Automated checks run
- Maintainer reviews code
- Feedback addressed
- Approval and merge
PR Template
## Summary
Brief description of changes.
## Changes
- Change 1
- Change 2
## Testing
How was this tested?
## Checklist
- [ ] Tests added
- [ ] Documentation updated
- [ ] `make tier2` passes
Architecture Decisions
For significant changes:
- Open an issue first
- Discuss the approach
- Get approval before implementing
Code of Conduct
Be respectful and constructive. We're all here to build something great together.
Getting Help
- Open an issue for questions
- Check existing issues first
- Join discussions on GitHub
License
By contributing, you agree that your contributions will be licensed under the MIT License.