MIT licensed and AI-native
Build worlds where intelligent agents are a core system, not an afterthought.
A free, MIT-licensed Rust game engine where AI agents are a first-class runtime system.
Deterministic simulation, tool-validated behavior, and Criterion-backed performance —
inspectable, reproducible, and open.
MIT license
Free to use, fork, and modify
Built for engine developers
Scope
Deterministic ECS, AI planning and validation, replay-safe simulation.
12,700+ agents at 60 FPS
977 Miri tests, 0 undefined behavior
71+ Kani proof harnesses
Architecture & methodology
Documented through a forensic trace campaign, not aspirational prose.
Interactive workspace map
Explore 71 production crates and 188 dependencies in your browser.
A Cytoscape.js visualization with blast-radius highlighting, dependency-path tracing,
domain filters, focus mode, an 8-step guided tour, and shareable URL state.
Click any node for crate detail; click any edge for the load-bearing types
crossing the boundary.
Architecture trace campaign
13 evidence-grounded subsystem traces, version-controlled with the code.
Each trace is a forensic reference: §5 file map, §6 conflict map, §7 decision log,
§8 invariants, §11 open questions. Traces document how each subsystem
actually works, separate from how older documentation aspirationally
describes it. The consolidated synthesis is the
2,500‑line architecture map .
Honest about what is in flight
Load-bearing core vs. designed-but-not-wired surface, both visible.
The map and visualization explicitly distinguish the production-wired core
(ECS, AI pipeline, rendering, terrain, editor) from
~200K LoC of dormant-but-designed surface (research-grade fluids,
LLM hardening, advanced GOAP, memory pipeline). Both are documented;
neither is sold as something it is not.
12,700+
validated agents at 60 FPS on modest consumer hardware
129
workspace members with comprehensive test coverage
977
Miri tests with zero undefined behavior
2.70 ms
frame time at 1,000 entities in the current benchmark baseline
71+
Kani proof harnesses across safety-critical crates
Quick navigation
Jump straight to the area you need.
Getting started
Set up the workspace and run your first system.
Architecture
Understand the runtime model.
Core systems
Explore the engine subsystems.
Examples and performance
Demos, benchmarks, and optimization.
Engine development
Contribute and build from source.
API and reference
Subsystem APIs and crate documentation.
Why AstraWeave
Architecture, safety, and performance you can verify.
AI-first architecture
Perception → reasoning → planning → action, built into the runtime.
World snapshots, plan intents, tool validation, behavior trees, GOAP, and LLM-backed
planning all plug into a deterministic loop.
Deterministic and safe
Bit-identical replay, validator-gated actions, and formal verification.
Core unsafe paths are exercised under Miri. Kani proofs back critical ECS, math,
and SDK invariants. Replay validation is built in, not bolted on.
Benchmarked subsystems
Every performance claim links to a reproducible Criterion measurement.
ECS, AI planning, physics, rendering frame times, and SIMD throughput are all
measured with specific numbers, not broad adjectives.
Engine loop
Perception → reasoning → planning → validation → action.
Perception
World snapshots from deterministic ECS state.
Reasoning
Behavior trees, utility systems, GOAP, or LLM logic.
Planning
Action sequences with costs, priorities, and fallbacks.
Validation
Cooldowns, LOS, pathing, and sandbox constraints.
Action
Approved commands flow into simulation, physics, and rendering.
What ships today
A focused stack for intelligent, simulation-heavy games.
AI orchestration
Six validated modes plus hybrid arbiters.
Classical planners, behavior trees, utility logic, LLM orchestration,
ensemble patterns, and hybrid arbiters.
Deterministic ECS
Ordered simulation for replay, tooling, and scale.
Archetype storage, system staging, iteration guarantees, and event channels
form a reproducible simulation backbone.
Rendering
wgpu-based rendering with real engine workloads.
PBR materials, clustered lighting, GPU skinning, post-processing, and LOD tooling.
Physics and movement
Rapier-backed dynamics with character controllers and spatial queries.
Rigid bodies, character controllers, collision detection, raycasting and shape
casting, joints, CCD. Fluid simulation (PBD SPH) and a SpatialHash broadphase
exist as research surface in the workspace but are not currently
production-wired — see the architecture map for the dormant-surface inventory.
Navigation and world systems
Navmesh, terrain, scene streaming, and gameplay layers.
Terrain generation, navigation meshes, crafting, quests, dialogue, and procedural content.
Tooling and integration
Example suite, editor tooling, and a C ABI for embedding.
A large example suite, editor workflows, and a stable C SDK layer.
Evidence-backed metrics
Current measurements from the codebase.
Agent capacity at 60 FPS 12,700+
AI validation throughput 6.48M checks/sec
Frame time at 1,000 entities 2.70 ms
ECS world creation 25.8 ns
Character move cost 114 ns
SIMD batch over 10k entities 9.879 us
Bake 10K Triangles
E2E Plan Gen · Cache Miss
Cache Latency · 200ms
Light Binning · High
Bloom Upsample · Mip 0
Light Binning · 5K
Cache Latency · 100ms
Game Loop · 5K Stress
100 ms
200 ms
300 ms
400 ms
500 ms
Bake 10K Triangles
548.16 ms
E2E Plan Gen · Cache Miss
218.79 ms
Cache Latency · 200ms
209.26 ms
Light Binning · High
176.06 ms
Bloom Upsample · Mip 0
172.55 ms
Light Binning · 5K
113.34 ms
Cache Latency · 100ms
108.52 ms
Game Loop · 5K Stress
93.91 ms
Quality posture
Verification and testing across the stack.
Weighted line coverage 59.3% across measured crates
High-coverage crates 14 crates at 85%+
Miri validation 977 tests, 0 UB
Kani verification 71+ harnesses
Prompt mutation testing 100% adjusted kill rate
Desktop targets Windows, Linux, macOS
Developer routes
Pick an entry point.
Use cases
Where AstraWeave fits best.
RPGs and immersive sims
Companions, directors, and systemic encounters.
Projects where NPCs need to observe, plan, and react with more depth than
state-machine scripting allows.
Server-authoritative multiplayer
Validation and replayability matter.
Deterministic simulation and tool-gated actions for anti-cheat,
reproducibility, and replay validation.
Research and prototyping
Benchmarkable AI-native architecture.
Test agent scale, planning strategies, and hybrid AI control
under measurable conditions.
Embedded engine teams
Rust core with a C ABI.
Adopt focused subsystems through the modular crate structure
instead of committing to the whole stack.
Design lineages
Games this engine could help realize.
Colony and world simulation
In the lineage of Dwarf Fortress or RimWorld.
Agent autonomy, world-state memory, logistics, and emergent story generation.
4X and grand strategy
In the lineage of Civilization or Crusader Kings.
Multi-agent diplomacy, advisor systems, strategic planners, and explainable AI.
Tactical command games
In the lineage of X-COM or Battle Brothers.
Tool validation, cover awareness, action planning, and replay-safe combat loops.
Systemic sandboxes
In the lineage of Kenshi or Mount and Blade.
Large numbers of autonomous actors with persistent world consequences.
Immersive sims and party RPGs
In the lineage of Deus Ex or Dragon Age.
Companion decisions, quest reactivity, systemic encounters, and director-style orchestration.
Rights-holder remakes
Licensed ports or original successors.
Rebuild ambitious systemic designs with modern AI-native architecture.
Next step
Clone, build, and decide from evidence.
AstraWeave is free and MIT licensed. Evaluate, adopt subsystems, or contribute back
without platform lock-in.
Get up and running with AstraWeave in minutes! This guide will help you install the engine, run your first AI companion, and understand the basic concepts.
Rust : 1.89.0+ (managed automatically via rust-toolchain.toml)
Platform : Linux, macOS, or Windows
GPU : Vulkan-compatible graphics card
Memory : 4GB+ RAM recommended for AI models
git clone https://github.com/lazyxeon/AstraWeave-AI-Native-Gaming-Engine.git
cd AstraWeave-AI-Native-Gaming-Engine
If you’re on Linux, install the required system packages:
sudo apt-get update
sudo apt-get install -y build-essential pkg-config cmake ninja-build \
libx11-dev libxi-dev libxcursor-dev libxrandr-dev libxinerama-dev \
libxkbcommon-dev libxkbcommon-x11-dev libx11-xcb-dev libxcb1-dev \
libxcb-randr0-dev libxcb-xfixes0-dev libxcb-shape0-dev libxcb-xkb-dev \
libgl1-mesa-dev libegl1-mesa-dev wayland-protocols libwayland-dev \
libasound2-dev libpulse-dev libudev-dev mesa-vulkan-drivers vulkan-tools
Build the stable, working core components:
cargo build -p astraweave-core -p astraweave-ai -p astraweave-physics \
-p astraweave-nav -p astraweave-render -p hello_companion
This typically takes 8-15 seconds after initial dependency download.
Let’s run the most basic example to see AstraWeave in action:
cargo run -p hello_companion --release
The demo will show:
AI Perception : The companion perceives the world state
Planning : AI generates a plan using its understanding
Tool Validation : The engine validates what the AI wants to do
Expected Panic : The demo will panic with “LosBlocked” - this is expected behavior demonstrating the validation system
[INFO] AI Companion initialized
[INFO] Perception snapshot captured: 1 entities
[INFO] Planning phase: generating intent for companion
[INFO] Generated plan: MoveTo(target_position)
[INFO] Validating tool usage: MovementTool
[ERROR] Validation failed: LosBlocked
thread 'main' panicked at 'LOS validation failed'
This panic is intentional! It demonstrates AstraWeave’s core principle: the AI can only do what the engine validates as possible .
The hello_companion example showcases AstraWeave’s fundamental architecture:
Fixed-Tick Simulation : The world runs at deterministic 60Hz
AI Perception : AI agents receive structured world snapshots
Planning Layer : AI generates intentions using LLM-based planning
Tool Validation : Engine validates every AI action before execution
Safety First : Invalid actions are rejected, maintaining game integrity
Now that you’ve seen the core loop in action:
If you encounter build errors:
Make sure you have the correct Rust version (check rust-toolchain.toml)
Install system dependencies for your platform
Some examples have known compilation issues - stick to the working core components listed above
Graphics errors : Ensure you have Vulkan drivers installed
Audio errors : Install audio system dependencies (ALSA/PulseAudio on Linux)
Permission errors : Make sure your user can access graphics and audio devices
For more help, see Troubleshooting .
🎉 Congratulations! You’ve successfully run your first AstraWeave AI companion. The engine validated the AI’s actions and maintained world integrity, just as it should.
This guide covers detailed installation instructions for AstraWeave on different platforms, including all dependencies and troubleshooting common issues.
CPU : x64 processor with SSE2 support
Memory : 4GB RAM (8GB+ recommended for AI models)
GPU : Vulkan 1.0 compatible graphics card
Storage : 2GB free space (more for AI models)
Rust : 1.89.0+ (managed via rust-toolchain.toml)
CPU : Multi-core x64 processor (4+ cores)
Memory : 16GB+ RAM for development and multiple AI models
GPU : Modern Vulkan 1.2+ compatible GPU with 2GB+ VRAM
Storage : SSD with 10GB+ free space
# Update package lists
sudo apt-get update
# Install build essentials
sudo apt-get install -y build-essential pkg-config cmake ninja-build
# Install graphics dependencies
sudo apt-get install -y libx11-dev libxi-dev libxcursor-dev libxrandr-dev \
libxinerama-dev libxkbcommon-dev libxkbcommon-x11-dev libx11-xcb-dev \
libxcb1-dev libxcb-randr0-dev libxcb-xfixes0-dev libxcb-shape0-dev \
libxcb-xkb-dev
# Install rendering dependencies
sudo apt-get install -y libgl1-mesa-dev libegl1-mesa-dev wayland-protocols \
libwayland-dev mesa-vulkan-drivers vulkan-tools
# Install audio dependencies
sudo apt-get install -y libasound2-dev libpulse-dev
# Install additional system dependencies
sudo apt-get install -y libudev-dev
# Install base development tools
sudo pacman -S base-devel cmake ninja
# Install graphics and audio
sudo pacman -S vulkan-devel mesa alsa-lib libpulse wayland wayland-protocols
# Install X11 dependencies
sudo pacman -S libx11 libxcb libxrandr libxinerama libxcursor libxi
# Install development tools
sudo dnf groupinstall "Development Tools"
sudo dnf install cmake ninja-build pkg-config
# Install graphics dependencies
sudo dnf install libX11-devel libXi-devel libXcursor-devel libXrandr-devel \
libXinerama-devel libxkbcommon-devel libxkbcommon-x11-devel
# Install Vulkan and Mesa
sudo dnf install vulkan-devel mesa-dri-drivers
# Install audio
sudo dnf install alsa-lib-devel pulseaudio-libs-devel
First, install Xcode Command Line Tools:
xcode-select --install
# Install Homebrew if not already installed
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install dependencies
brew install cmake ninja pkg-config
# For Intel Macs, ensure MoltenVK is installed
brew install molten-vk
Download and install Xcode from the App Store
Install CMake from cmake.org
Ensure MoltenVK is available for Vulkan support
Install Visual Studio 2019 or later with C++ build tools
Install Git for Windows
Install CMake (either standalone or via Visual Studio Installer)
# Install MSYS2 from https://www.msys2.org/
# Then in MSYS2 terminal:
pacman -S mingw-w64-x86_64-cmake mingw-w64-x86_64-ninja
pacman -S mingw-w64-x86_64-vulkan-devel
AstraWeave uses a specific Rust version defined in rust-toolchain.toml. The installation process will automatically use the correct version.
# Install rustup (Rust installer)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Follow the prompts, then restart your terminal or run:
source ~/.cargo/env
# Verify installation
rustc --version
cargo --version
The following components will be installed automatically when needed:
cargo - Package manager and build tool
clippy - Linter for catching common mistakes
rustfmt - Code formatter
rust-analyzer - Language server for IDE support
git clone https://github.com/lazyxeon/AstraWeave-AI-Native-Gaming-Engine.git
cd AstraWeave-AI-Native-Gaming-Engine
The correct Rust version will be installed automatically:
# This will show the version from rust-toolchain.toml
rustc --version
Start with the stable, working components:
cargo build -p astraweave-core -p astraweave-ai -p astraweave-physics \
-p astraweave-nav -p astraweave-render -p hello_companion
Verify the installation with tests:
cargo test -p astraweave-input
Test the installation:
cargo run -p hello_companion --release
# Linux: Check Vulkan
vulkaninfo | grep "deviceName"
# macOS: Check Metal
system_profiler SPDisplaysDataType
# Windows: Use dxdiag or GPU-Z
# Linux: Test audio devices
aplay -l
# macOS: Check audio
system_profiler SPAudioDataType
# Windows: Check audio devices in Device Manager
Install these extensions:
rust-analyzer - Rust language support
CodeLLDB - Debugging support
Even Better TOML - TOML file support
Error Lens - Inline error display
CLion : Has good Rust support with the Rust plugin
Vim/Neovim : Use with rust-analyzer LSP
Emacs : Use with rust-analyzer and rustic-mode
For better performance during development:
# Always use release mode for examples
cargo run -p hello_companion --release
# Build in release mode
cargo build --release
Speed up builds by using multiple CPU cores:
# Set in ~/.cargo/config.toml
[build]
jobs = 4 # or number of CPU cores
Use a shared target directory to reduce disk usage:
# Set CARGO_TARGET_DIR environment variable
export CARGO_TARGET_DIR=/path/to/shared/target
Linux : Install build-essential or gcc
macOS : Install Xcode Command Line Tools
Windows : Install Visual Studio with C++ tools
Linux : Install mesa-vulkan-drivers and vulkan-tools
macOS : Ensure MoltenVK is installed
Windows : Update graphics drivers
Linux : Install libasound2-dev and libpulse-dev
macOS : Usually works out of the box
Windows : Ensure Windows Audio service is running
Use cargo build --release for better runtime performance
Consider using sccache to cache compilation results
Increase parallel build jobs in Cargo config
Always use --release flag for examples and demos
Ensure GPU drivers are up to date
Check system has adequate RAM (4GB minimum)
AstraWeave supports both Wayland and X11:
# Force X11 if needed
export WAYLAND_DISPLAY=""
# Force Wayland if needed
export DISPLAY=""
For distribution on macOS, you may need to sign binaries:
codesign --force --deep --sign - target/release/hello_companion
Some antivirus software may flag Rust binaries. Add exclusions for:
The project directory
~/.cargo directory
target/ build directory
With AstraWeave installed:
Run through the Quick Start Guide
Explore Working Examples
Read about Architecture
Build Your First Game
For ongoing development, see the Contributing Guide and Building from Source .
This tutorial will guide you through creating your first AI companion in AstraWeave. By the end, you’ll have a companion that can perceive its environment, experience emotions, and exhibit autonomous behaviors.
Before starting, ensure you have:
New to Rust? Check out [The Rust Book](https://doc.rust-lang.org/book/) for a comprehensive introduction.
# Create a new Rust project
cargo new my_first_companion
cd my_first_companion
Edit Cargo.toml to add AstraWeave dependencies:
[package]
name = "my_first_companion"
version = "0.1.0"
edition = "2021"
[dependencies]
# Core AstraWeave crates
astraweave-ai = "0.1.0"
astraweave-render = "0.1.0"
bevy = "0.12"
# Utilities
anyhow = "1.0"
Version numbers may change. Check [crates.io](https://crates.io) for the latest versions.
# Build to ensure dependencies are resolved
cargo build
Let’s start with a simple companion that just exists in the world.
Edit src/main.rs:
use bevy::prelude::*;
use astraweave_ai::prelude::*;
use anyhow::Result;
fn main() -> Result<()> {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(AstraWeaveAIPlugin)
.add_systems(Startup, setup_companion)
.add_systems(Update, update_companion)
.run();
Ok(())
}
fn setup_companion(mut commands: Commands) {
// Create a basic companion
let companion = CompanionBuilder::new()
.with_name("Buddy")
.build();
commands.spawn(companion);
info!("Spawned companion: Buddy");
}
fn update_companion(
mut companions: Query<&mut Companion>,
time: Res<Time>,
) {
for mut companion in companions.iter_mut() {
companion.update(time.delta_seconds());
}
}
cargo run
You should see log output indicating your companion was spawned!
Congratulations! You've created your first AI companion. Now let's make it more interesting.
Companions need to perceive their environment to interact meaningfully.
Add visual perception to detect nearby entities:
#![allow(unused)]
fn main() {
use astraweave_ai::perception::*;
fn setup_companion(mut commands: Commands) {
let companion = CompanionBuilder::new()
.with_name("Buddy")
.with_perception(PerceptionConfig {
visual_range: 10.0,
visual_fov: 120.0, // 120-degree field of view
update_rate: 10.0, // 10 updates per second
..default()
})
.build();
commands.spawn(companion);
}
}
Create entities that the companion can perceive:
#![allow(unused)]
fn main() {
fn setup_companion(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// Spawn the companion
let companion = CompanionBuilder::new()
.with_name("Buddy")
.with_perception(PerceptionConfig {
visual_range: 10.0,
visual_fov: 120.0,
update_rate: 10.0,
..default()
})
.build();
commands.spawn((
companion,
Transform::from_xyz(0.0, 0.0, 0.0),
));
// Spawn a ball that the companion can see
commands.spawn((
PbrBundle {
mesh: meshes.add(Sphere::new(0.5)),
material: materials.add(Color::rgb(0.8, 0.2, 0.2)),
transform: Transform::from_xyz(3.0, 0.5, 0.0),
..default()
},
Stimulus::new(StimulusType::Visual, 1.0),
));
// Add camera
commands.spawn(Camera3dBundle {
transform: Transform::from_xyz(0.0, 5.0, 10.0)
.looking_at(Vec3::ZERO, Vec3::Y),
..default()
});
// Add light
commands.spawn(PointLightBundle {
point_light: PointLight {
intensity: 1500.0,
shadows_enabled: true,
..default()
},
transform: Transform::from_xyz(4.0, 8.0, 4.0),
..default()
});
}
}
Add a system to log what the companion perceives:
#![allow(unused)]
fn main() {
fn update_companion(
mut companions: Query<(&mut Companion, &Transform)>,
stimuli: Query<(&Stimulus, &Transform)>,
time: Res<Time>,
) {
for (mut companion, companion_transform) in companions.iter_mut() {
companion.update(time.delta_seconds());
// Check what the companion can see
if let Some(perception) = companion.perception() {
for (stimulus, stimulus_transform) in stimuli.iter() {
let distance = companion_transform
.translation
.distance(stimulus_transform.translation);
if distance <= perception.visual_range {
info!(
"{} sees {} stimulus at distance {:.2}",
companion.name(),
stimulus.stimulus_type,
distance
);
}
}
}
}
}
}
Emotions make companions feel alive and responsive.
Add emotion processing to your companion:
#![allow(unused)]
fn main() {
use astraweave_ai::emotion::*;
fn setup_companion(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
let companion = CompanionBuilder::new()
.with_name("Buddy")
.with_perception(PerceptionConfig {
visual_range: 10.0,
visual_fov: 120.0,
update_rate: 10.0,
..default()
})
.with_emotions(vec![
EmotionConfig::new("joy", 0.5, 0.95),
EmotionConfig::new("curiosity", 0.3, 0.98),
EmotionConfig::new("calm", 0.7, 0.99),
])
.build();
commands.spawn((
companion,
Transform::from_xyz(0.0, 0.0, 0.0),
));
// ... spawn stimuli and camera as before
}
}
Make emotions respond to stimuli:
fn process_emotions(
mut companions: Query<&mut Companion>,
stimuli: Query<(&Stimulus, &Transform)>,
) {
for mut companion in companions.iter_mut() {
if let Some(emotion_system) = companion.emotion_system_mut() {
// Process nearby stimuli
for (stimulus, _) in stimuli.iter() {
match stimulus.stimulus_type {
StimulusType::Visual => {
emotion_system.increase_emotion("curiosity", 0.1);
}
StimulusType::Positive => {
emotion_system.increase_emotion("joy", 0.2);
}
StimulusType::Negative => {
emotion_system.decrease_emotion("calm", 0.15);
}
_ => {}
}
}
// Log dominant emotion
if let Some(dominant) = emotion_system.dominant_emotion() {
info!(
"{} feels {} (intensity: {:.2})",
companion.name(),
dominant.name,
dominant.intensity
);
}
}
}
}
// Add this system to your app
fn main() -> Result<()> {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(AstraWeaveAIPlugin)
.add_systems(Startup, setup_companion)
.add_systems(Update, (update_companion, process_emotions))
.run();
Ok(())
}
Behaviors allow companions to act autonomously based on their internal state.
Add a simple behavior that makes the companion wander:
#![allow(unused)]
fn main() {
use astraweave_ai::behavior::*;
fn setup_companion(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
let companion = CompanionBuilder::new()
.with_name("Buddy")
.with_perception(PerceptionConfig::default())
.with_emotions(vec![
EmotionConfig::new("joy", 0.5, 0.95),
EmotionConfig::new("curiosity", 0.3, 0.98),
EmotionConfig::new("calm", 0.7, 0.99),
])
.with_behavior(BehaviorConfig {
wander_speed: 2.0,
wander_radius: 5.0,
..default()
})
.build();
commands.spawn((
companion,
PbrBundle {
mesh: meshes.add(Capsule3d::new(0.5, 1.0)),
material: materials.add(Color::rgb(0.3, 0.5, 0.8)),
transform: Transform::from_xyz(0.0, 0.5, 0.0),
..default()
},
));
// ... rest of scene setup
}
}
Make behavior change based on emotions:
fn emotion_driven_behavior(
mut companions: Query<(&mut Companion, &mut Transform)>,
time: Res<Time>,
) {
for (mut companion, mut transform) in companions.iter_mut() {
if let Some(emotion_system) = companion.emotion_system() {
let curiosity = emotion_system
.get_emotion("curiosity")
.map(|e| e.intensity)
.unwrap_or(0.0);
let calm = emotion_system
.get_emotion("calm")
.map(|e| e.intensity)
.unwrap_or(0.0);
// High curiosity = move around more
if curiosity > 0.6 {
let speed = 2.0 * curiosity;
let direction = Vec3::new(
(time.elapsed_seconds() * 0.5).sin(),
0.0,
(time.elapsed_seconds() * 0.5).cos(),
);
transform.translation += direction * speed * time.delta_seconds();
info!("{} is exploring curiously!", companion.name());
}
// High calm = stay still
else if calm > 0.7 {
info!("{} is resting calmly.", companion.name());
}
}
}
}
// Update main to include the behavior system
fn main() -> Result<()> {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(AstraWeaveAIPlugin)
.add_systems(Startup, setup_companion)
.add_systems(Update, (
update_companion,
process_emotions,
emotion_driven_behavior,
))
.run();
Ok(())
}
Here’s the full code with all features:
use bevy::prelude::*;
use astraweave_ai::prelude::*;
use anyhow::Result;
fn main() -> Result<()> {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "My First Companion".to_string(),
resolution: (1280.0, 720.0).into(),
..default()
}),
..default()
}))
.add_plugins(AstraWeaveAIPlugin)
.add_systems(Startup, setup_companion)
.add_systems(Update, (
update_companion,
process_emotions,
emotion_driven_behavior,
))
.run();
Ok(())
}
fn setup_companion(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// Create companion
let companion = CompanionBuilder::new()
.with_name("Buddy")
.with_perception(PerceptionConfig {
visual_range: 10.0,
visual_fov: 120.0,
update_rate: 10.0,
..default()
})
.with_emotions(vec![
EmotionConfig::new("joy", 0.5, 0.95),
EmotionConfig::new("curiosity", 0.3, 0.98),
EmotionConfig::new("calm", 0.7, 0.99),
])
.with_behavior(BehaviorConfig::default())
.build();
commands.spawn((
companion,
PbrBundle {
mesh: meshes.add(Capsule3d::new(0.5, 1.0)),
material: materials.add(Color::rgb(0.3, 0.5, 0.8)),
transform: Transform::from_xyz(0.0, 0.5, 0.0),
..default()
},
));
// Add ground plane
commands.spawn(PbrBundle {
mesh: meshes.add(Plane3d::new(Vec3::Y, Vec2::new(20.0, 20.0))),
material: materials.add(Color::rgb(0.3, 0.5, 0.3)),
..default()
});
// Add interactive objects
for i in 0..5 {
let angle = (i as f32 / 5.0) * std::f32::consts::TAU;
let pos = Vec3::new(angle.cos() * 4.0, 0.5, angle.sin() * 4.0);
commands.spawn((
PbrBundle {
mesh: meshes.add(Sphere::new(0.5)),
material: materials.add(Color::rgb(0.8, 0.2, 0.2)),
transform: Transform::from_translation(pos),
..default()
},
Stimulus::new(StimulusType::Visual, 1.0),
));
}
// Add camera
commands.spawn(Camera3dBundle {
transform: Transform::from_xyz(0.0, 8.0, 12.0)
.looking_at(Vec3::ZERO, Vec3::Y),
..default()
});
// Add light
commands.spawn(PointLightBundle {
point_light: PointLight {
intensity: 1500.0,
shadows_enabled: true,
..default()
},
transform: Transform::from_xyz(4.0, 8.0, 4.0),
..default()
});
}
fn update_companion(
mut companions: Query<&mut Companion>,
time: Res<Time>,
) {
for mut companion in companions.iter_mut() {
companion.update(time.delta_seconds());
}
}
fn process_emotions(
mut companions: Query<(&mut Companion, &Transform)>,
stimuli: Query<(&Stimulus, &Transform)>,
) {
for (mut companion, companion_transform) in companions.iter_mut() {
if let Some(emotion_system) = companion.emotion_system_mut() {
for (stimulus, stimulus_transform) in stimuli.iter() {
let distance = companion_transform
.translation
.distance(stimulus_transform.translation);
if distance <= 5.0 {
emotion_system.increase_emotion("curiosity", 0.05);
emotion_system.increase_emotion("joy", 0.02);
}
}
}
}
}
fn emotion_driven_behavior(
mut companions: Query<(&mut Companion, &mut Transform)>,
time: Res<Time>,
) {
for (companion, mut transform) in companions.iter_mut() {
if let Some(emotion_system) = companion.emotion_system() {
let curiosity = emotion_system
.get_emotion("curiosity")
.map(|e| e.intensity)
.unwrap_or(0.0);
if curiosity > 0.4 {
let speed = 1.5 * curiosity;
let direction = Vec3::new(
(time.elapsed_seconds() * 0.8).sin(),
0.0,
(time.elapsed_seconds() * 0.8).cos(),
);
transform.translation += direction * speed * time.delta_seconds();
transform.translation.y = 0.5; // Keep on ground
}
}
}
}
cargo run --release
Watch your companion:
Wander around the scene
React to nearby objects
Show emotional responses
Exhibit autonomous behavior
You've created a fully functional AI companion with perception, emotions, and behaviors!
Now that you have a working companion, try:
Add more emotions : Experiment with fear, excitement, anger
Complex behaviors : Implement approach/avoid behaviors based on emotions
Social interactions : Add multiple companions that interact
Save/Load : Persist companion state between runs
Visual feedback : Change companion color based on dominant emotion
Check out more examples in the repository:
# Clone AstraWeave
git clone https://github.com/verdentlabs/astraweave.git
cd astraweave
# Run advanced examples
cargo run --release --example companion_emotions
cargo run --release --example multi_companion
cargo run --release --example behavior_showcase
Share your companion creations in our Discord showcase channel!
Happy companion building!
This page outlines the hardware and software requirements for developing and running AstraWeave applications.
These are the bare minimum specifications to run AstraWeave applications:
Component Specification
CPU Quad-core processor (Intel i5-6600K / AMD Ryzen 3 1300X or equivalent)
RAM 8 GB
GPU NVIDIA GTX 1060 (6GB) / AMD RX 580 (8GB) / Intel Arc A380
VRAM 4 GB
Storage 2 GB available space (SSD recommended)
Display 1920x1080 resolution
Minimum requirements may result in reduced performance. For optimal experience, see recommended specifications.
Windows : Windows 10 (64-bit) version 1909 or later
Linux : Any modern distribution with kernel 5.4+ (Ubuntu 20.04, Fedora 33, etc.)
macOS : macOS 10.15 (Catalina) or later
For optimal performance and development experience:
Component Specification
CPU 8-core processor (Intel i7-9700K / AMD Ryzen 7 3700X or better)
RAM 16 GB or more
GPU NVIDIA RTX 3060 / AMD RX 6700 XT / Intel Arc A750 or better
VRAM 8 GB or more
Storage 10 GB available space on NVMe SSD
Display 2560x1440 or higher resolution
Windows : Windows 11 (64-bit)
Linux : Ubuntu 22.04 LTS / Fedora 38 or later
macOS : macOS 13 (Ventura) or later
More RAM and VRAM allow for larger scenes, more AI companions, and higher-quality assets.
AstraWeave is GPU-accelerated and requires modern graphics hardware with compute shader support.
Your GPU must support one of the following:
Platform Graphics API Minimum Version
Windows DirectX 12 Feature Level 12.0
Windows/Linux Vulkan 1.2
macOS Metal Metal 2
Minimum :
GTX 1060 (6GB)
GTX 1070
GTX 1660 Ti
Recommended :
RTX 3060 or better
RTX 4060 or better
RTX A2000 or better (workstation)
Optimal :
RTX 4070 Ti or better
RTX 4080 / 4090
RTX A4000 or better (workstation)
Minimum :
RX 580 (8GB)
RX 5500 XT (8GB)
RX 6600
Recommended :
RX 6700 XT or better
RX 7600 XT or better
Optimal :
RX 7800 XT or better
RX 7900 XTX
Radeon Pro W6800 or better (workstation)
Minimum :
Recommended :
Arc A750 or better
Arc A770
Optimal :
Intel Arc GPUs require the latest drivers for optimal performance. Visit [Intel's driver page](https://www.intel.com/content/www/us/en/download/726609/intel-arc-graphics-windows-dch-driver.html) for updates.
Integrated GPUs are not recommended but may work for basic development:
Intel Iris Xe (11th gen or later)
AMD Radeon Graphics (Ryzen 5000 series or later)
Integrated graphics will have significant performance limitations. Dedicated GPU strongly recommended.
# Check DirectX version
dxdiag
# Check GPU with PowerShell
Get-WmiObject Win32_VideoController | Select-Object Name, DriverVersion
# Check Vulkan support
vulkaninfo | grep "deviceName"
# Check GPU details
lspci | grep -i vga
nvidia-smi # For NVIDIA GPUs
# Check Metal support
system_profiler SPDisplaysDataType
Debian/Ubuntu :
sudo apt install -y \
build-essential \
pkg-config \
libx11-dev \
libxcursor-dev \
libxrandr-dev \
libxi-dev \
libasound2-dev \
libudev-dev \
vulkan-tools \
libvulkan-dev
Fedora/RHEL :
sudo dnf install -y \
gcc gcc-c++ \
pkg-config \
libX11-devel \
libXcursor-devel \
libXrandr-devel \
libXi-devel \
alsa-lib-devel \
systemd-devel \
vulkan-tools \
vulkan-loader-devel
Arch Linux :
sudo pacman -S --needed \
base-devel \
libx11 \
libxcursor \
libxrandr \
libxi \
alsa-lib \
vulkan-tools \
vulkan-headers
Always use the latest drivers for optimal performance:
Download from NVIDIA Driver Downloads
Linux: Use distribution’s package manager or NVIDIA’s .run installer
Minimum driver version: 525.60.11 (Linux) / 528.24 (Windows)
Windows Defender : May slow initial builds. Add exclusion for project directory:
Settings > Windows Security > Virus & threat protection > Exclusion settings
Long Path Support : Enable for deep project structures:
# Run as Administrator
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" `
-Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force
Power Settings : Use “High Performance” or “Ultimate Performance” power plan for best results
GPU Permissions : Ensure user is in the video group:
sudo usermod -a -G video $USER
# Log out and back in
Wayland : Some features work better with X11. Switch if experiencing issues:
# Edit /etc/gdm3/custom.conf (Ubuntu/Debian)
# Uncomment: WaylandEnable=false
Memory Limits : Increase limits for large projects:
# Add to ~/.bashrc
ulimit -n 4096
Metal Support : Requires macOS 10.15+ and Metal-compatible GPU
Check: system_profiler SPDisplaysDataType | grep Metal
Apple Silicon (M1/M2/M3) : Fully supported with native ARM builds
# Verify ARM toolchain
rustc --version --verbose | grep host
# Should show: aarch64-apple-darwin
Rosetta 2 : Not required for M1/M2/M3 Macs (native ARM support)
Security Settings : May need to allow apps in System Preferences on first run
Specification Compile Time (full rebuild) Test Suite Runtime Editor Performance
Minimum 5-10 minutes 2-3 minutes Playable (30+ FPS)
Recommended 2-4 minutes 1-2 minutes Smooth (60+ FPS)
Optimal 1-2 minutes <1 minute Excellent (120+ FPS)
Example scene: 10 AI companions with full perception and emotion systems
Specification Average FPS 1% Low FPS Max Companions
Minimum 30-45 25-30 10-20
Recommended 60-90 50-60 50-100
Optimal 120-144+ 90-100 200-500+
Performance varies based on scene complexity, AI behavior count, and graphics settings.
Run this command to verify your setup:
# Clone and run the system check example
git clone https://github.com/verdentlabs/astraweave.git
cd astraweave
cargo run --example system_check
The system check will report:
Rust version
GPU capabilities
Graphics API support
Available memory
Recommended settings
If all checks pass, your system is ready for AstraWeave development!
If your system doesn’t meet requirements:
GPU : Biggest impact on runtime performance
RAM : Enables larger scenes and faster compilation
SSD : Dramatically reduces build times
CPU : Improves compilation speed and AI performance
Entry Level ($300-500): Used RTX 3060 or RX 6700 XT + 16GB RAM
Mid Range ($800-1200): RTX 4060 Ti or RX 7700 XT + 32GB RAM + NVMe SSD
High End ($2000+): RTX 4080 or RX 7900 XTX + 64GB RAM + Fast CPU
Once your system meets the requirements:
Install AstraWeave
Build from source
Create your first companion
If you have questions about requirements, join our Discord community .
AstraWeave represents a fundamental shift in game engine design: instead of treating AI as an afterthought, it places intelligent agents at the core of the architecture. This document explains the key architectural decisions and how they enable truly AI-native gameplay.
This page is a conceptual orientation. For evidence-grounded, version-controlled
detail on how the engine actually works, the architecture trace campaign produces
authoritative references:
* **[Interactive workspace map](https://lazyxeon.github.io/AstraWeave-AI-Native-Gaming-Engine/architecture/)** — Cytoscape.js visualisation of 71 production
crates and 188 dependencies with blast-radius highlighting, an 8-step guided
tour, dependency-path finder, focus mode, and per-crate trace links.
* **[`ARCHITECTURE_MAP.md`](https://github.com/lazyxeon/AstraWeave-AI-Native-Gaming-Engine/blob/main/docs/architecture/ARCHITECTURE_MAP.md)** — 2,500-line consolidated synthesis covering crate dependency
graph, structural axioms, dormant-code inventory, integration seams, data-flow
paths, and 23 cross-cutting open questions.
* **[13 per-subsystem traces](https://github.com/lazyxeon/AstraWeave-AI-Native-Gaming-Engine/tree/main/docs/architecture)** under `docs/architecture/` — each a forensic reference with file map,
conflict map, decision log, invariants, and open questions. Trace docs are
treated as part of the production contract (per `CLAUDE.md`) — when subsystem
code changes, the trace updates in the same commit.
The trace toolkit (5 prompt templates under `docs/architecture/_meta/`) makes the
methodology re-runnable as the codebase evolves.
Traditional game engines follow this pattern:
Game Logic → AI System → Scripted Behaviors
AstraWeave inverts this relationship:
AI Agents ← Tool Validation ← Engine Authority
No Cheating AI : AI can only act through validated game systems
Emergent Behavior : Complex interactions emerge from simple, validated tools
Multiplayer Ready : Same validation works for human players and AI
Predictable Performance : Deterministic simulation enables reliable testing
graph TB
subgraph "Fixed-Tick Simulation (60Hz)"
ECS[ECS World]
Physics[Physics System]
Audio[Audio System]
Render[Render System]
end
subgraph "AI Pipeline"
Perception[Perception Bus]
Planning[AI Planning]
Tools[Tool Sandbox]
Validation[Engine Validation]
end
subgraph "Game Layer"
Gameplay[Gameplay Systems]
Content[Content Systems]
UI[UI Systems]
end
ECS --> Perception
Perception --> Planning
Planning --> Tools
Tools --> Validation
Validation --> ECS
ECS --> Physics
ECS --> Audio
ECS --> Render
Gameplay --> ECS
Content --> ECS
UI --> ECS
AstraWeave runs the simulation at exactly 60Hz, regardless of rendering framerate:
#![allow(unused)]
fn main() {
const TICK_RATE: f64 = 60.0;
const TICK_DURATION: Duration = Duration::from_nanos(16_666_667); // 1/60 second
// Simulation always advances by exactly this amount
fn tick_simulation(&mut self) {
self.world.step(TICK_DURATION);
}
}
Benefits:
Deterministic physics and AI behavior
Consistent timing across different hardware
Reliable networking and replay systems
Predictable performance testing
AstraWeave uses an archetype-based ECS for cache-friendly performance:
#![allow(unused)]
fn main() {
// Components are pure data
#[derive(Component)]
struct Position(Vec3);
#[derive(Component)]
struct AIAgent {
perception_range: f32,
planning_cooldown: Duration,
}
// Systems operate on component combinations
fn ai_perception_system(
query: Query<(&Position, &AIAgent, &mut PerceptionState)>
) {
for (pos, agent, mut perception) in query.iter_mut() {
// Update agent perception based on world state
}
}
}
Key Benefits:
Cache-friendly data layout
Parallel system execution
Clean separation of data and logic
Easy to reason about and test
AI agents receive structured snapshots of the world state:
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize)]
pub struct PerceptionSnapshot {
pub timestamp: u64,
pub agent_id: EntityId,
pub visible_entities: Vec<EntityData>,
pub audio_events: Vec<AudioEvent>,
pub world_state: WorldState,
}
}
Design Principles:
Filtered Information : Agents only perceive what they should be able to see/hear
Structured Data : JSON-serializable for easy AI model consumption
Temporal Consistency : Snapshots include timing information
Bandwidth Efficient : Only relevant changes are included
AI models generate plans using the perception data:
#![allow(unused)]
fn main() {
pub struct AIPlan {
pub agent_id: EntityId,
pub intent: Intent,
pub tools: Vec<ToolUsage>,
pub confidence: f32,
pub reasoning: String, // For debugging and learning
}
pub enum Intent {
MoveTo { target: Vec3, urgency: f32 },
AttackTarget { target: EntityId, weapon: Option<EntityId> },
Interact { target: EntityId, interaction_type: String },
Communicate { target: EntityId, message: String },
}
}
Key Features:
High-Level Intents : AI thinks in terms of goals, not implementation
Tool-Based Actions : All actions go through validated tools
Confidence Scoring : Enables dynamic difficulty and behavior tuning
Explainable Reasoning : For debugging and player understanding
Every AI action must be validated by the engine:
#![allow(unused)]
fn main() {
pub trait Tool {
fn validate(&self, world: &World, usage: &ToolUsage) -> ValidationResult;
fn execute(&self, world: &mut World, usage: &ToolUsage) -> ExecutionResult;
}
// Example: Movement tool
impl Tool for MovementTool {
fn validate(&self, world: &World, usage: &ToolUsage) -> ValidationResult {
let agent = world.get::<Position>(usage.agent_id)?;
let target = usage.target_position;
// Check line of sight
if !world.line_of_sight(agent.0, target) {
return ValidationResult::Blocked(BlockReason::LineOfSight);
}
// Check movement cooldown
if !self.cooldown_ready(usage.agent_id) {
return ValidationResult::Blocked(BlockReason::Cooldown);
}
ValidationResult::Valid
}
fn execute(&self, world: &mut World, usage: &ToolUsage) -> ExecutionResult {
// Actually perform the movement
world.get_mut::<Position>(usage.agent_id)?.0 = usage.target_position;
ExecutionResult::Success
}
}
}
Validation Categories:
Physics Constraints : Can the action physically happen?
Resource Requirements : Does the agent have what’s needed?
Cooldowns : Is the action available now?
Line of Sight : Can the agent see the target?
Game Rules : Does this follow the game’s rules?
AstraWeave uses server-authoritative validation for multiplayer:
Client AI → Intent → Server Validation → World Update → State Broadcast
Benefits:
No client-side cheating possible
Consistent world state across all clients
AI agents validated same as human players
Deterministic simulation enables easy rollback
Instead of replicating low-level actions, AstraWeave replicates high-level intents:
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize)]
pub struct NetworkIntent {
pub player_id: PlayerId,
pub intent: Intent,
pub timestamp: u64,
pub predicted_outcome: Option<PredictedResult>,
}
}
Advantages:
Lower bandwidth than action replication
Natural lag compensation through prediction
AI and human intents handled identically
Easy to implement anti-cheat
ECS Archetype Iteration : Cache-friendly component access
Parallel Systems : Independent systems run in parallel
Incremental Updates : Only changed data is processed
Fixed Timestep : Predictable CPU load
Pool Allocation : Entities and components use object pools
Streaming : World chunks loaded/unloaded based on relevance
Compression : Perception data compressed before AI processing
Garbage Collection : Minimal allocations in hot paths
Deferred Rendering : Efficient handling of many lights
Instanced Rendering : Batch similar objects
Compute Shaders : Physics and AI calculations on GPU
Temporal Upsampling : Maintain quality at lower resolution
astraweave-core/ # ECS, validation, core types
astraweave-ai/ # AI planning and perception
astraweave-physics/ # Physics integration
astraweave-render/ # Rendering pipeline
astraweave-audio/ # Audio system
astraweave-nav/ # Navigation and pathfinding
astraweave-net/ # Networking layer
#![allow(unused)]
fn main() {
pub trait EnginePlugin {
fn build(&self, app: &mut App);
}
pub struct CustomGamePlugin;
impl EnginePlugin for CustomGamePlugin {
fn build(&self, app: &mut App) {
app.add_system(custom_ai_behavior_system)
.add_tool(CustomInteractionTool)
.register_component::<CustomComponent>();
}
}
}
AI agents cannot:
Access arbitrary memory
Execute arbitrary code
Bypass tool validation
Affect systems outside their permissions
AI agents can only:
Receive perception data
Generate high-level intents
Use validated tools
Learn from feedback
The deterministic simulation enables:
Replay Verification : Detect desync and cheating
Formal Verification : Mathematical proof of certain properties
Predictable Testing : Reliable automated testing
Audit Trails : Complete history of all decisions
#![allow(unused)]
fn main() {
// Instead of inheritance hierarchies
class AIAgent extends Entity { ... }
// Use component composition
struct Entity {
components: HashMap<ComponentId, Box<dyn Component>>
}
}
#![allow(unused)]
fn main() {
// Behavior configured through data
#[derive(Deserialize)]
struct AIProfile {
aggression: f32,
curiosity: f32,
risk_tolerance: f32,
preferred_tools: Vec<String>,
}
}
#![allow(unused)]
fn main() {
// Every system is pure and testable
fn ai_planning_system(
world: &World,
perceptions: &[PerceptionSnapshot]
) -> Vec<AIPlan> {
// Pure function - easy to test
}
}
Aspect Traditional Engine AstraWeave
AI Integration Bolted-on scripting Core architecture
Action Validation Trust-based Engine-validated
Determinism Variable Fixed-tick
Networking Action replication Intent replication
Performance Variable Predictable
Testing Difficult Deterministic
To understand specific systems:
To start building:
AstraWeave’s AI-native design represents a fundamental shift in how game engines approach artificial intelligence. Instead of treating AI as an add-on feature, we’ve built the entire engine around the principle that AI agents are first-class citizens .
Most game engines follow this pattern:
Game Engine → Game Logic → AI Scripting Layer → NPC Behaviors
Problems with this approach:
AI is disconnected from core game systems
AI agents can cheat (access hidden information, ignore physics)
Difficult to create consistent multiplayer behavior
AI behavior is scripted, not emergent
Hard to test and debug AI interactions
AstraWeave inverts this relationship:
AI Agents ← Tool Validation ← Engine Core ← Game Logic
Benefits of this approach:
AI and human players use identical systems
No AI cheating possible
Emergent behavior from simple rules
Natural multiplayer compatibility
Testable and debuggable AI behavior
AI agents only know what they can perceive:
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize)]
pub struct PerceptionSnapshot {
// Only information the AI should have access to
pub visible_entities: Vec<EntityData>,
pub audible_events: Vec<AudioEvent>,
pub remembered_information: Vec<MemoryItem>,
pub world_constraints: Vec<Constraint>,
}
}
No omniscience : AI cannot access:
Hidden game state
Other agents’ thoughts
Perfect world information
Player UI state
Debug information
Realistic limitations : AI must work within:
Line of sight restrictions
Hearing range limitations
Memory capacity constraints
Processing time limits
AI generates high-level intents, not low-level commands:
#![allow(unused)]
fn main() {
pub enum Intent {
// High-level goals
ExploreArea { target_region: Region, curiosity: f32 },
SeekCover { threat_direction: Vec3, urgency: f32 },
ProtectAlly { ally_id: EntityId, commitment: f32 },
// Not low-level commands like "move left 3 pixels"
}
}
Benefits:
AI thinks strategically, not tactically
Natural language mapping for LLMs
Easy to understand and debug
Platform and implementation independent
All AI actions go through validated tools:
#![allow(unused)]
fn main() {
pub trait Tool {
// Every action must be validated first
fn validate(&self, world: &World, usage: &ToolUsage) -> ValidationResult;
// Only valid actions are executed
fn execute(&self, world: &mut World, usage: &ToolUsage) -> ExecutionResult;
// Tools have constraints and cooldowns
fn get_constraints(&self) -> ToolConstraints;
}
}
No direct world manipulation : AI cannot:
Teleport entities
Spawn infinite resources
Ignore physics constraints
Break game rules
#![allow(unused)]
fn main() {
fn perception_system(
mut agents: Query<(&Position, &AIAgent, &mut PerceptionState)>,
world_entities: Query<&Position, &EntityType>,
audio_events: Res<AudioEventBuffer>,
) {
for (pos, agent, mut perception) in agents.iter_mut() {
let mut snapshot = PerceptionSnapshot::new();
// Gather visible entities (line of sight)
for (other_pos, entity_type) in world_entities.iter() {
if world.line_of_sight(pos.0, other_pos.0) {
let distance = pos.0.distance(other_pos.0);
if distance <= agent.vision_range {
snapshot.visible_entities.push(EntityData {
position: other_pos.0,
entity_type: entity_type.clone(),
distance,
});
}
}
}
// Gather audible events
for event in audio_events.iter() {
let distance = pos.0.distance(event.position);
let volume = event.calculate_volume_at_distance(distance);
if volume > agent.hearing_threshold {
snapshot.audible_events.push(event.clone());
}
}
// Include relevant memories
snapshot.remembered_information = agent.memory.query_relevant(
&snapshot,
agent.memory_capacity
);
perception.current_snapshot = Some(snapshot);
}
}
}
#![allow(unused)]
fn main() {
fn ai_planning_system(
mut agents: Query<(&AIAgent, &PerceptionState, &mut PlanningState)>,
ai_service: Res<AIService>,
) {
for (agent, perception, mut planning) in agents.iter_mut() {
if let Some(snapshot) = &perception.current_snapshot {
// Prepare input for AI model
let planning_request = PlanningRequest {
agent_profile: agent.profile.clone(),
perception_data: snapshot.clone(),
available_tools: tool_registry.get_available_tools(agent.id),
current_goals: agent.goal_stack.clone(),
recent_memory: agent.memory.get_recent(10),
};
// Generate plan using LLM
match ai_service.generate_plan(planning_request) {
Ok(plan) => {
info!("AI generated plan: {:?}", plan);
planning.current_plan = Some(plan);
},
Err(e) => {
warn!("AI planning failed: {}", e);
// Fallback to simple behaviors
planning.current_plan = Some(generate_fallback_plan(agent));
}
}
}
}
}
}
#![allow(unused)]
fn main() {
fn tool_validation_system(
mut agents: Query<(&PlanningState, &mut ActionQueue)>,
tool_registry: Res<ToolRegistry>,
world: &World,
) {
for (planning, mut actions) in agents.iter_mut() {
if let Some(plan) = &planning.current_plan {
for tool_usage in &plan.tool_usages {
let tool = tool_registry.get_tool(&tool_usage.tool_name)?;
match tool.validate(world, tool_usage) {
ValidationResult::Valid => {
actions.push(ValidatedAction {
tool_usage: tool_usage.clone(),
validation_timestamp: world.current_tick(),
});
},
ValidationResult::Blocked(reason) => {
warn!("Tool validation failed: {:?}", reason);
// AI learns from failure
agent.memory.record_failure(tool_usage, reason);
},
ValidationResult::Delayed(wait_time) => {
actions.push_delayed(tool_usage.clone(), wait_time);
}
}
}
}
}
}
}
#![allow(unused)]
fn main() {
fn action_execution_system(
mut agents: Query<&mut ActionQueue>,
tool_registry: Res<ToolRegistry>,
mut world: ResMut<World>,
) {
for mut actions in agents.iter_mut() {
while let Some(action) = actions.pop_ready() {
let tool = tool_registry.get_tool(&action.tool_usage.tool_name)?;
match tool.execute(&mut world, &action.tool_usage) {
ExecutionResult::Success => {
// AI learns from success
agent.memory.record_success(&action.tool_usage);
},
ExecutionResult::Failed(reason) => {
// Even validated actions can fail during execution
warn!("Action execution failed: {:?}", reason);
agent.memory.record_execution_failure(&action.tool_usage, reason);
}
}
}
}
}
}
In AstraWeave, tools represent what an agent can do , not what it will do :
#![allow(unused)]
fn main() {
pub struct MovementTool {
max_speed: f32,
acceleration: f32,
valid_surfaces: Vec<SurfaceType>,
}
impl Tool for MovementTool {
fn validate(&self, world: &World, usage: &ToolUsage) -> ValidationResult {
// Check if movement is physically possible
let agent_pos = world.get::<Position>(usage.agent_id)?;
let target_pos = usage.parameters.get_vec3("target")?;
// Line of sight check
if !world.line_of_sight(agent_pos.0, target_pos) {
return ValidationResult::Blocked(BlockReason::ObstructedPath);
}
// Surface validity check
let surface_type = world.get_surface_type(target_pos);
if !self.valid_surfaces.contains(&surface_type) {
return ValidationResult::Blocked(BlockReason::InvalidSurface);
}
// Speed limit check
let distance = agent_pos.0.distance(target_pos);
let time_required = distance / self.max_speed;
if time_required > usage.max_execution_time {
return ValidationResult::Blocked(BlockReason::TooSlow);
}
ValidationResult::Valid
}
}
}
Complex behaviors emerge from combining simple tools:
#![allow(unused)]
fn main() {
// AI plans using multiple tools in sequence
let complex_plan = AIPlan {
steps: vec![
ToolUsage {
tool_name: "MovementTool",
parameters: movement_params,
},
ToolUsage {
tool_name: "InteractionTool",
parameters: interaction_params,
},
ToolUsage {
tool_name: "CommunicationTool",
parameters: communication_params,
},
],
};
}
#![allow(unused)]
fn main() {
pub struct AIMemory {
// Short-term working memory
working_memory: VecDeque<MemoryItem>,
// Long-term episodic memory
episodic_memory: Vec<Episode>,
// Learned patterns and strategies
strategy_memory: HashMap<Situation, Strategy>,
// Failed actions and why they failed
failure_memory: Vec<FailureRecord>,
}
impl AIMemory {
pub fn record_success(&mut self, action: &ToolUsage, outcome: &ExecutionResult) {
// Reinforce successful strategies
let situation = self.extract_situation_features(action);
let strategy = self.extract_strategy_features(action);
self.strategy_memory.entry(situation)
.or_default()
.reinforce(strategy, outcome.success_metric());
}
pub fn record_failure(&mut self, action: &ToolUsage, reason: &BlockReason) {
// Learn from failures to avoid them
self.failure_memory.push(FailureRecord {
action: action.clone(),
reason: reason.clone(),
context: self.current_context.clone(),
timestamp: Instant::now(),
});
}
}
}
#![allow(unused)]
fn main() {
fn adaptation_system(
mut agents: Query<(&mut AIAgent, &AIMemory)>,
) {
for (mut agent, memory) in agents.iter_mut() {
// Adjust behavior based on recent experiences
let recent_failures = memory.get_recent_failures(Duration::from_secs(300));
if recent_failures.iter().any(|f| matches!(f.reason, BlockReason::TooAggressive)) {
agent.profile.aggression *= 0.9; // Become less aggressive
}
if recent_failures.iter().any(|f| matches!(f.reason, BlockReason::TooSlow)) {
agent.profile.urgency *= 1.1; // Become more urgent
}
// Adapt strategy preferences
let successful_strategies = memory.get_successful_strategies();
for (situation, strategy) in successful_strategies {
agent.strategy_preferences.insert(situation, strategy);
}
}
}
}
When multiple AI agents need to navigate through a narrow passage:
#![allow(unused)]
fn main() {
// No explicit coordination code needed
// Emergent behavior arises from:
// 1. Each agent perceives others as obstacles
// 2. Movement tool validates non-collision
// 3. Agents naturally take turns or find alternate routes
}
#![allow(unused)]
fn main() {
// Agents can form alliances based on shared threats
fn threat_response_planning(
agent: &AIAgent,
perception: &PerceptionSnapshot,
) -> Intent {
let threats = perception.identify_threats();
let potential_allies = perception.identify_potential_allies();
if threats.is_empty() {
return Intent::Explore { target: random_area() };
}
if potential_allies.is_empty() {
return Intent::Flee { threat_direction: threats[0].position };
}
// Emergent alliance formation
Intent::CoordinateDefense {
allies: potential_allies,
threat: threats[0],
strategy: choose_defensive_strategy(threats, potential_allies),
}
}
}
#![allow(unused)]
fn main() {
// AI learns and counters player strategies
fn combat_planning(
agent: &AIAgent,
perception: &PerceptionSnapshot,
memory: &AIMemory,
) -> Intent {
let player = perception.find_player()?;
// Analyze player's recent tactics
let player_patterns = memory.analyze_player_behavior(&player);
// Choose counter-strategy
let counter_strategy = match player_patterns.primary_tactic {
PlayerTactic::RushAttack => CombatStrategy::DefensiveCounter,
PlayerTactic::RangedKiting => CombatStrategy::ClosingPincer,
PlayerTactic::DefensiveTurtle => CombatStrategy::AreaDenial,
PlayerTactic::Unpredictable => CombatStrategy::AdaptiveReactive,
};
Intent::ExecuteCombatStrategy {
target: player.entity_id,
strategy: counter_strategy,
commitment: calculate_commitment(player_patterns.skill_level),
}
}
}
#![allow(unused)]
fn main() {
// AI planning can be expensive, so we use various optimizations:
pub struct AIService {
// LLM inference can be slow
model_cache: LRUCache<PlanningRequest, AIPlan>,
// Batch multiple planning requests
batch_processor: BatchProcessor<PlanningRequest>,
// Use cheaper models for simple decisions
model_hierarchy: Vec<AIModel>, // Fast → Accurate
}
impl AIService {
pub fn generate_plan(&self, request: PlanningRequest) -> Result<AIPlan> {
// Check cache first
if let Some(cached_plan) = self.model_cache.get(&request) {
return Ok(cached_plan.clone());
}
// Use appropriate model based on complexity
let model = self.select_model(request.complexity());
// Generate plan
let plan = model.generate_plan(request)?;
// Cache result
self.model_cache.insert(request, plan.clone());
Ok(plan)
}
}
}
#![allow(unused)]
fn main() {
// AI memory systems need careful management
impl AIMemory {
pub fn cleanup_old_memories(&mut self) {
// Remove memories older than threshold
let cutoff = Instant::now() - Duration::from_secs(3600); // 1 hour
self.episodic_memory.retain(|episode| episode.timestamp > cutoff);
// Compress similar memories
self.compress_similar_episodes();
// Keep only the most important failures
self.failure_memory.sort_by_key(|f| f.importance_score());
self.failure_memory.truncate(100); // Keep top 100
}
}
}
#![allow(unused)]
fn main() {
#[derive(Debug, Serialize)]
pub struct AIPlan {
pub intent: Intent,
pub reasoning: String, // Natural language explanation
pub confidence: f32,
pub alternative_plans: Vec<AlternativePlan>,
pub decision_factors: Vec<DecisionFactor>,
}
// Example reasoning output:
"I can see an enemy at position (10, 5) who appears to be low on health.
My ally is engaged in combat nearby and could use support. I have a clear
line of sight and my weapon is ready. I'm choosing to attack rather than
flank because the enemy seems focused on my ally and won't see me coming."
}
#![allow(unused)]
fn main() {
// In development builds, expose AI decision making
#[cfg(debug_assertions)]
impl AIAgent {
pub fn get_debug_info(&self) -> AIDebugInfo {
AIDebugInfo {
current_perception: self.perception.clone(),
active_plan: self.planning.current_plan.clone(),
recent_decisions: self.memory.get_recent_decisions(10),
personality_state: self.profile.clone(),
tool_availability: self.get_available_tools(),
}
}
}
}
#![allow(unused)]
fn main() {
// AI respects physics constraints
impl Tool for MovementTool {
fn validate(&self, world: &World, usage: &ToolUsage) -> ValidationResult {
let physics_world = world.resource::<PhysicsWorld>();
let agent_body = physics_world.get_body(usage.agent_id)?;
// Check if movement would cause collision
let proposed_movement = usage.parameters.get_vec3("target")?;
if physics_world.would_collide(agent_body, proposed_movement) {
return ValidationResult::Blocked(BlockReason::PhysicsCollision);
}
ValidationResult::Valid
}
}
}
#![allow(unused)]
fn main() {
// AI actions trigger appropriate animations
impl Tool for CombatTool {
fn execute(&self, world: &mut World, usage: &ToolUsage) -> ExecutionResult {
let attack_type = usage.parameters.get_string("attack_type")?;
// Trigger combat animation
world.get_mut::<AnimationController>(usage.agent_id)?
.play_animation(format!("attack_{}", attack_type));
// Execute combat logic
self.resolve_combat(world, usage)
}
}
}
Aspect Traditional Approach AstraWeave AI-Native
Decision Making Scripted state machines LLM-based planning
World Knowledge Omniscient access Perception-limited
Action Execution Direct world manipulation Tool-validated actions
Behavior Adaptation Manual script updates Automatic learning
Multiplayer Separate AI/player code Unified validation
Debugging Complex state inspection Natural language reasoning
Performance Predictable overhead Variable AI complexity
Emergence Limited by scripts Unbounded combinations
#![allow(unused)]
fn main() {
// Good: Define what an agent CAN do
pub struct InteractionTool {
pub interaction_range: f32,
pub valid_targets: Vec<EntityType>,
pub cooldown: Duration,
}
// Avoid: Scripting what an agent WILL do
// pub fn npc_behavior_script() { ... }
}
#![allow(unused)]
fn main() {
// AI failures are features, not bugs
if let Err(validation_error) = tool.validate(world, usage) {
// Don't just log the error - let the AI learn from it
agent.memory.record_lesson(validation_error, current_context);
// AI will avoid this mistake in similar situations
}
}
#![allow(unused)]
fn main() {
// Give AI agents the information they need to make good decisions
pub struct PerceptionSnapshot {
// Not just positions, but meaningful context
pub entities: Vec<EntityPerception>,
pub environmental_cues: Vec<EnvironmentalCue>,
pub social_context: SocialContext,
pub recent_events: Vec<GameEvent>,
}
}
#![allow(unused)]
fn main() {
// Break complex goals into manageable sub-goals
pub enum Intent {
// High-level strategic goals
DefendTerritory { area: Region },
// Mid-level tactical goals
EstablishDefensivePosition { chokepoint: Vec3 },
// Low-level operational goals
MoveToCover { cover_position: Vec3 },
}
}
Multi-Agent Planning : Coordinated group decision making
Hierarchical Temporal Memory : Better long-term memory systems
Causal Reasoning : Understanding cause-and-effect relationships
Meta-Learning : AI that learns how to learn better
Neural Network Compression : Smaller, faster AI models
Predictive Caching : Pre-compute likely AI decisions
Distributed Processing : AI planning across multiple cores/machines
Hybrid Approaches : Combine neural networks with symbolic reasoning
AI-native design is not just about making NPCs smarter - it’s about creating fundamentally new types of interactive experiences where AI agents are true participants in the game world, subject to the same rules and constraints as human players.
AstraWeave uses an archetype-based Entity Component System (ECS) designed for deterministic, AI-native game development. The architecture provides cache-friendly iteration, deterministic execution ordering, and efficient event propagation for AI perception.
An Entity is a unique identifier representing a game object. Internally, entities use a 32-bit ID and 32-bit generation counter for safe recycling:
#![allow(unused)]
fn main() {
use astraweave_ecs::*;
let mut world = World::new();
// Spawn returns a unique Entity handle
let player = world.spawn();
let enemy = world.spawn();
// Check if entity is alive (generation-safe)
assert!(world.is_alive(player));
// Despawn removes entity and all components
world.despawn(enemy);
assert!(!world.is_alive(enemy));
}
The generation counter prevents “dangling entity” bugs—if you hold a stale entity handle after despawn, operations silently fail rather than affecting the recycled entity.
A Component is data attached to an entity. Any 'static + Send + Sync type automatically implements Component:
#![allow(unused)]
fn main() {
// Components are just plain structs
#[derive(Clone, Copy)]
struct Position { x: f32, y: f32 }
#[derive(Clone, Copy)]
struct Velocity { x: f32, y: f32 }
struct Health(i32);
// Insert components
let mut world = World::new();
let e = world.spawn();
world.insert(e, Position { x: 0.0, y: 0.0 });
world.insert(e, Velocity { x: 1.0, y: 0.0 });
world.insert(e, Health(100));
// Query components
if let Some(pos) = world.get::<Position>(e) {
println!("Entity at ({}, {})", pos.x, pos.y);
}
// Mutate components
if let Some(health) = world.get_mut::<Health>(e) {
health.0 -= 10;
}
}
A Resource is a singleton value accessible across systems—perfect for shared state like input, time, or game configuration:
#![allow(unused)]
fn main() {
struct DeltaTime(f32);
struct InputState {
move_direction: (f32, f32),
attack_pressed: bool,
}
let mut world = World::new();
// Insert resources
world.insert_resource(DeltaTime(1.0 / 60.0));
world.insert_resource(InputState {
move_direction: (0.0, 0.0),
attack_pressed: false,
});
// Query resources
let dt = world.get_resource::<DeltaTime>().unwrap();
let input = world.get_resource::<InputState>().unwrap();
}
An Archetype groups all entities with the same set of component types. When you add or remove components, the entity moves to a different archetype.
Archetype 0: [Position]
├── Entity(1): Position(0,0)
└── Entity(4): Position(5,3)
Archetype 1: [Position, Velocity]
├── Entity(2): Position(1,1), Velocity(1,0)
└── Entity(3): Position(2,2), Velocity(0,1)
Archetype 2: [Position, Velocity, Health]
└── Entity(5): Position(3,3), Velocity(1,1), Health(100)
This design provides:
Cache-friendly iteration : Components of the same type are stored contiguously
Efficient queries : Filter by archetype signature, not per-entity checks
Predictable memory layout : Improves CPU prefetching
AstraWeave supports two storage backends:
Box Mode (Legacy) : Components stored as Box<dyn Any>. Works for any component type but has heap indirection overhead.
BlobVec Mode (Optimized) : Components stored in contiguous byte arrays. Requires component registration but provides 2-10× faster iteration:
#![allow(unused)]
fn main() {
let mut world = World::new();
// Register component for optimized storage
world.register_component::<Position>();
world.register_component::<Velocity>();
// Now Position and Velocity use BlobVec storage
}
Entity-to-archetype mapping uses a SparseSet for O(1) lookup:
Memory Layout:
sparse: [None, Some(0), None, Some(1), None, Some(2), ...]
↓ ↓ ↓
dense: [Entity(1), Entity(3), Entity(5), ...]
This replaced the previous BTreeMap approach, providing 12-57× faster entity lookups.
AstraWeave uses fixed stages for deterministic execution—critical for AI agents that must produce identical behavior across game sessions:
#![allow(unused)]
fn main() {
use astraweave_ecs::*;
// System stages execute in order
pub struct SystemStage;
impl SystemStage {
pub const PRE_SIMULATION: &'static str = "pre_simulation";
pub const PERCEPTION: &'static str = "perception"; // Build AI snapshots
pub const SIMULATION: &'static str = "simulation"; // Game logic
pub const AI_PLANNING: &'static str = "ai_planning"; // Generate plans
pub const PHYSICS: &'static str = "physics"; // Apply forces
pub const POST_SIMULATION: &'static str = "post_simulation";
pub const PRESENTATION: &'static str = "presentation"; // Render, audio
}
}
The AI-native game loop follows: Perception → Reasoning → Planning → Action
┌───────────────────────────────────────────────────────────────────────┐
│ Frame N │
├─────────────┬─────────────┬──────────────┬─────────────┬─────────────┤
│ pre_sim │ perception │ simulation │ ai_planning │ physics │
│ (setup) │ (sensors) │ (game logic) │ (decide) │ (movement) │
├─────────────┴─────────────┴──────────────┴─────────────┴─────────────┤
│ post_sim → presentation │
└───────────────────────────────────────────────────────────────────────┘
Systems are functions that operate on the World:
#![allow(unused)]
fn main() {
fn movement_system(world: &mut World) {
world.each_mut::<Position>(|entity, pos| {
if let Some(vel) = world.get::<Velocity>(entity) {
pos.x += vel.x;
pos.y += vel.y;
}
});
}
fn ai_perception_system(world: &mut World) {
// Build WorldSnapshot for AI agents
// (see AI documentation for details)
}
let mut app = App::new();
app.add_system("simulation", movement_system);
app.add_system("perception", ai_perception_system);
}
AstraWeave provides ergonomic query iterators:
#![allow(unused)]
fn main() {
// Single-component read-only query
let query = Query::<Position>::new(&world);
for (entity, pos) in query {
println!("Entity {:?} at ({}, {})", entity, pos.x, pos.y);
}
// Two-component query
let query2 = Query2::<Position, Velocity>::new(&world);
for (entity, pos, vel) in query2 {
println!("Entity {:?} moving at ({}, {})", entity, vel.x, vel.y);
}
// Mutable queries
let mut query = Query2Mut::<Position, Velocity>::new(&mut world);
for (entity, pos, vel) in query.iter_mut() {
pos.x += vel.x;
pos.y += vel.y;
}
}
Events enable reactive AI behaviors and decoupled communication between systems.
#![allow(unused)]
fn main() {
use astraweave_ecs::*;
// Define custom events
#[derive(Clone)]
struct DamageEvent {
target: Entity,
amount: i32,
source: Option<Entity>,
}
impl Event for DamageEvent {}
// Send events via Events resource
let mut events = Events::new();
events.send(DamageEvent {
target: player,
amount: 25,
source: Some(enemy),
});
}
#![allow(unused)]
fn main() {
// Systems read events via EventReader
fn damage_system(world: &mut World) {
let mut events = world.get_resource_mut::<Events>().unwrap();
for event in events.drain::<DamageEvent>() {
if let Some(health) = world.get_mut::<Health>(event.target) {
health.0 -= event.amount;
}
}
}
}
AstraWeave provides common events for AI perception:
Event Purpose
EntitySpawnedEventEntity creation notification
EntityDespawnedEventEntity removal notification
HealthChangedEventHealth changes (for AI threat assessment)
AiPlanningFailedEventAI plan generation failures
ToolValidationFailedEventAI action validation failures
The App struct provides a Bevy-like builder pattern:
use astraweave_ecs::*;
#[derive(Clone, Copy)]
struct Position { x: f32, y: f32 }
#[derive(Clone, Copy)]
struct Velocity { x: f32, y: f32 }
fn movement_system(world: &mut World) {
world.each_mut::<Position>(|entity, pos| {
if let Some(vel) = world.get::<Velocity>(entity) {
pos.x += vel.x;
pos.y += vel.y;
}
});
}
fn main() {
let mut app = App::new();
app.add_system("simulation", movement_system);
// Spawn entities
let e = app.world.spawn();
app.world.insert(e, Position { x: 0.0, y: 0.0 });
app.world.insert(e, Velocity { x: 1.0, y: 0.0 });
// Run 100 simulation ticks
app = app.run_fixed(100);
// Entity moved 100 units
let pos = app.world.get::<Position>(e).unwrap();
assert_eq!(pos.x, 100.0);
}
Plugins encapsulate related systems and resources:
#![allow(unused)]
fn main() {
pub trait Plugin {
fn build(&self, app: &mut App);
}
struct PhysicsPlugin;
impl Plugin for PhysicsPlugin {
fn build(&self, app: &mut App) {
app.add_system("physics", physics_tick);
app.world.insert_resource(PhysicsConfig::default());
}
}
// Add plugins to app
let app = App::new().add_plugin(PhysicsPlugin);
}
For deferred operations (avoiding borrow conflicts), use CommandBuffer:
#![allow(unused)]
fn main() {
use astraweave_ecs::*;
// Register components first
let mut world = World::new();
world.register_component::<Position>();
world.register_component::<Velocity>();
// Queue commands
let mut cmd = CommandBuffer::new();
cmd.spawn()
.insert(Position { x: 0.0, y: 0.0 })
.insert(Velocity { x: 1.0, y: 0.0 });
cmd.entity(existing_entity)
.remove::<Velocity>();
// Apply all commands at once
cmd.apply(&mut world);
}
AstraWeave ECS is designed for deterministic replay and multiplayer synchronization:
Entities within an archetype iterate in spawn order (using packed arrays), ensuring consistent system behavior.
Use the built-in deterministic RNG:
#![allow(unused)]
fn main() {
use astraweave_ecs::Rng;
let mut rng = Rng::from_seed(42);
let damage = rng.next_f32() * 10.0; // Same value every time with seed 42
}
Events are stored in order-of-send, with frame tracking for cleanup:
#![allow(unused)]
fn main() {
let events = Events::new()
.with_keep_frames(2); // Keep events for 2 frames
}
Benchmarked on the AstraWeave test suite (Week 10):
Operation Time Notes
World creation 25.8 ns Empty world
Entity spawn 420 ns Includes archetype assignment
Component insert 1-2 µs Archetype migration if needed
Entity lookup O(1) SparseSet, 12-57× faster than BTreeMap
Iteration (per entity) <1 ns Packed array iteration
Query creation 50-100 ns Archetype filtering
With 16.67 ms per frame, current performance provides:
1,000 entities : 1.14 ms frame time (93% headroom)
10,000 entities : ~11 ms frame time (34% headroom)
Movement system : 106 µs for 1,000 entities (9.4× faster post-optimization)
For specialized use cases, you can interact with archetype storage directly:
#![allow(unused)]
fn main() {
// Access archetypes for low-level iteration
for archetype in world.archetypes().iter() {
println!("Archetype {} has {} entities",
archetype.id, archetype.len());
for &entity in archetype.entities_vec() {
// Direct entity access
}
}
}
Enable the profiling feature for Tracy integration:
[dependencies]
astraweave-ecs = { version = "0.4", features = ["profiling"] }
Key spans are automatically instrumented: ECS::World::spawn, ECS::World::get, ECS::Schedule::run.
AstraWeave provides bit-identical deterministic simulation across all core systems. This enables reproducible AI behavior, lockstep multiplayer, replay systems, and reliable regression testing.
For AI-native gameplay, determinism is critical :
#![allow(unused)]
fn main() {
// Without determinism (BAD):
// Run 1: Entity 42 attacks first (random HashMap iteration)
// Run 2: Entity 17 attacks first (different outcome!)
// Multiplayer: Host and client see different results
// With determinism (GOOD):
// Run 1, 2, 3...: Same entity order, same decisions, same outcome
// Multiplayer: All clients see identical simulation
// Replays: Record inputs, reproduce exact gameplay
}
Use Cases:
Reproducible AI : Same world state → same AI decisions
Lockstep Multiplayer : Only sync inputs, not full game state
Replay Systems : Record/playback for debugging and spectating
Regression Testing : Validate AI changes don’t break behavior
Competitive Gaming : Provably fair gameplay
System Guarantee
ECS Iteration Archetypes visited in ID order (BTreeMap)
Within-Archetype Entities maintain relative order
Repeated Iterations Same order every time
Cross-World Same operations → same archetype IDs
Physics Deterministic parallel iteration (Rayon)
Capture/Replay Bit-identical state restoration
Events FIFO ordering
Spawn order across archetypes : Entity order changes when components added/removed
Floating-point across platforms : Different CPUs may produce slightly different results
Non-deterministic RNG : Must use seeded RNG explicitly
AstraWeave runs physics and AI at exactly 60Hz:
#![allow(unused)]
fn main() {
const TICK_RATE: f64 = 60.0;
const TICK_DURATION: Duration = Duration::from_nanos(16_666_667);
// Every simulation step advances by exactly this amount
pub fn step(world: &mut World, cfg: &SimConfig) {
world.tick(cfg.dt); // cfg.dt = 1/60 second
}
}
Benefits:
Consistent timing across different hardware
Decoupled from render frame rate
Predictable performance testing
Reliable networking
The ECS uses BTreeMap instead of HashMap for archetype storage:
#![allow(unused)]
fn main() {
/// CRITICAL: BTreeMap for deterministic iteration by ID
pub struct ArchetypeSet {
archetypes: BTreeMap<ArchetypeId, Archetype>,
// NOT HashMap - iteration order would be non-deterministic!
}
}
Why this matters:
HashMap iteration order depends on hash function and memory layout
BTreeMap iteration order is sorted by key (deterministic)
AI agents iterate entities in identical order every run
#![allow(unused)]
fn main() {
// Entities are stored per-archetype
let e1 = world.spawn(); // Empty archetype (ID 0)
let e2 = world.spawn(); // Empty archetype (ID 0)
world.insert(e1, Position::new(1.0, 1.0)); // Moves e1 to Position archetype (ID 1)
// Iteration order: [e2, e1]
// - Archetype 0 (empty) visited first: contains e2
// - Archetype 1 (Position) visited second: contains e1
// NOT spawn order [e1, e2] - but IS deterministic!
}
If spawn order is critical, track it explicitly:
#![allow(unused)]
fn main() {
#[derive(Component, Clone, Copy)]
struct SpawnOrder(u64);
// When spawning:
let e = world.spawn();
world.insert(e, SpawnOrder(world.entity_count() as u64));
// When iterating in spawn order:
let mut entities = world.entities_with::<SpawnOrder>();
entities.sort_by_key(|&e| world.get::<SpawnOrder>(e).unwrap().0);
}
AstraWeave provides state capture for debugging and determinism validation:
#![allow(unused)]
fn main() {
use astraweave_core::{capture_state, replay_state, SimConfig};
// Capture world state at tick 100
capture_state(100, "save.json", &world)?;
// Later: Replay from saved state
let cfg = SimConfig { dt: 1.0 / 60.0 };
let world = replay_state("save.json", 100, &cfg)?; // Run 100 ticks
}
{
"tick": 100,
"world": {
"t": 1.6666,
"next_id": 42,
"obstacles": [[5, 10], [0, 0], [15, 20]]
}
}
Stability guarantees:
Obstacles sorted for consistent serialization
JSON format for human readability
Minimal state for fast save/load
Physics uses Rayon with deterministic iteration:
#![allow(unused)]
fn main() {
/// Parallel iterator helpers for deterministic physics
impl PhysicsParallelOps {
/// Process rigid bodies in parallel (deterministic order)
pub fn par_process_bodies<F>(&self, bodies: &mut [RigidBody], f: F)
where
F: Fn(&mut RigidBody) + Sync,
{
// Rayon's par_iter preserves element order
bodies.par_iter_mut().for_each(f);
}
}
}
Physics simulation uses fixed timesteps:
#![allow(unused)]
fn main() {
// Physics world with deterministic gravity
let physics = PhysicsWorld::new(Vec3::new(0.0, -9.81, 0.0));
// Fixed step (not variable dt!)
physics.step(1.0 / 60.0); // Always 16.67ms
}
The ECS includes comprehensive determinism tests:
#![allow(unused)]
fn main() {
#[test]
fn test_query_iteration_deterministic() {
let mut world = World::new();
// Spawn entities with components
for i in 0..100 {
let e = world.spawn();
world.insert(e, Position { x: i as f32, y: 0.0 });
}
// First iteration
let order1: Vec<Entity> = world.entities_with::<Position>().collect();
// Second iteration - must be identical
let order2: Vec<Entity> = world.entities_with::<Position>().collect();
assert_eq!(order1, order2, "Iteration order must be deterministic");
}
}
#![allow(unused)]
fn main() {
#[test]
fn test_determinism_across_runs() {
let results: Vec<Vec<Entity>> = (0..5)
.map(|_| {
let mut world = World::new();
// Same operations each run
setup_world(&mut world);
world.entities_with::<AIAgent>().collect()
})
.collect();
// All runs must produce identical results
for run in &results[1..] {
assert_eq!(&results[0], run);
}
}
}
#![allow(unused)]
fn main() {
// ✅ Use fixed timestep
world.step(FIXED_DT);
// ✅ Use seeded RNG
let mut rng = StdRng::seed_from_u64(12345);
// ✅ Use BTreeMap for deterministic iteration
let entities: BTreeMap<EntityId, Entity> = ...;
// ✅ Sort before iteration if order matters
let mut entities = query.iter().collect::<Vec<_>>();
entities.sort_by_key(|e| e.id());
}
#![allow(unused)]
fn main() {
// ❌ Variable timestep
world.step(delta_time);
// ❌ Unseeded RNG
let mut rng = rand::thread_rng();
// ❌ HashMap for gameplay-critical iteration
let entities: HashMap<EntityId, Entity> = ...;
// ❌ Rely on spawn order across archetype changes
let first = world.entities().next(); // Order may change!
}
Determinism adds minimal overhead:
Operation HashMap BTreeMap Overhead
Lookup O(1) O(log n) ~10-20 ns
Iteration O(n) O(n) None
Insert O(1) O(log n) ~10-20 ns
In practice: For typical games (<10,000 entities), the overhead is negligible (<1% of frame budget).
astraweave-ecs/src/determinism_tests.rs - 763 lines of determinism validation
astraweave-core/src/capture_replay.rs - State capture/replay implementation
astraweave-physics/src/async_scheduler.rs - Deterministic parallel physics
The AstraWeave LLM integration includes a comprehensive tool validation system that ensures LLM-generated plans are safe and comply with the game’s allowed actions.
The tool validation system works in three layers:
Schema Validation : Ensures JSON structure matches expected format
Tool Registry Validation : Verifies all actions are in the allowed tool set
Engine Validation : Runtime checks for cooldowns, line-of-sight, and other constraints
The ToolRegistry defines which tools are available to the LLM:
#![allow(unused)]
fn main() {
pub struct ToolRegistry {
pub tools: Vec<ToolSpec>,
pub constraints: Constraints,
}
pub struct ToolSpec {
pub name: String,
pub args: BTreeMap<String, String>, // argument name -> type
}
}
LLM Response Parsing : Parse the JSON response into a PlanIntent
Tool Allowlist Check : Verify each action is in the registry
Type Validation : Ensure arguments match expected types (future enhancement)
Runtime Validation : Engine checks constraints during execution
MoveTo : Move companion to specified coordinates
Throw : Throw items (smoke, grenade) at target location
CoverFire : Provide covering fire at target for duration
Revive : Revive allied units
The system provides clear error messages for validation failures:
Invalid JSON format
Disallowed tools used by LLM
Missing required arguments
Type mismatches (future)
Allowlist-only : Only pre-approved actions can be executed
No dynamic code execution : All actions are statically defined
Input sanitization : JSON parsing prevents injection attacks
Constraint enforcement : Runtime validation prevents illegal moves
#![allow(unused)]
fn main() {
use astraweave_llm::{parse_llm_plan, MockLlm, plan_from_llm};
// Create tool registry
let registry = ToolRegistry {
tools: vec![
ToolSpec {
name: "move_to".into(),
args: [("x", "i32"), ("y", "i32")].into_iter().collect(),
}
],
constraints: Constraints::default(),
};
// Parse and validate LLM response
let plan = parse_llm_plan(llm_response, ®istry)?;
// Full end-to-end validation
let plan = plan_from_llm(&client, &world_snapshot, ®istry).await?;
}
The validation system includes comprehensive tests covering:
Valid plan parsing
Invalid JSON handling
Disallowed tool detection
Empty plan handling
All action types
Error message verification
Run tests with:
cargo test -p astraweave-llm
Argument type validation
Parameter range checking
Cost/resource validation
Complex constraint evaluation
Custom validation plugins
AstraWeave’s AI system is the core differentiator of the engine. Unlike traditional game engines where AI is an afterthought, AstraWeave treats AI agents as first-class citizens that interact with the game world through validated tools.
In AstraWeave, AI agents cannot cheat. They must use the same validated game systems as players, ensuring fair and emergent gameplay.
The architecture trace campaign documented eight AI subsystems (per
[`ai_pipeline.md`](https://github.com/lazyxeon/AstraWeave-AI-Native-Gaming-Engine/blob/main/docs/architecture/ai_pipeline.md) §13). Wired in the runtime today:
* **`astraweave-ai`** — `AIArbiter`, `Orchestrator` trait, `LlmExecutor`, `tool_sandbox`.
Validated at 12,700+ agents @ 60 FPS.
* **`astraweave-behavior`** — canonical GOAP (`BTreeMap<u32, bool>` `WorldState`,
interned keys, LRU plan cache) and Behavior Trees.
* **`astraweave-llm`** — Ollama client adapters. Runtime model default is
**`phi3:medium`** (`orchestrator.rs:488-490`), set via the `OLLAMA_MODEL` env var.
Three clients (Phi3, Hermes2Pro, Qwen3) coexist; Qwen3 is supported but is not
the runtime default despite doc-comment phrasing like "GOAP+Qwen3 Hybrid."
Dormant in-design (passes tests, zero production callers — per
[`ai_pipeline.md`](https://github.com/lazyxeon/AstraWeave-AI-Native-Gaming-Engine/blob/main/docs/architecture/ai_pipeline.md) §13 and `ARCHITECTURE_MAP.md` §5.1):
* **Memory pipeline (~11K LoC)** — zero in-engine production consumers; only the
legacy `persona::*` types are used.
* **Coordination crate (~5.3K)** — zero workspace consumers; three commented-out
`pub mod` declarations point at source files that were never created.
* **Advanced GOAP (~16.7K)** — feature `planner_advanced`, parallel to the
canonical GOAP in `astraweave-behavior` (Q2 in §14).
* **LLM Production Hardening (~15K)** — rate limiting, circuit breakers, A/B
routing, retry, telemetry, ToolGuard, 4-tier fallback. The runtime `AIArbiter`
path bypasses this entire surface.
* **RAG composite (~12.3K)** — `RagPipeline` held as field by five LLM-enhanced
consumer crates, all themselves dormant. The advertised HNSW vector index in
`astraweave-embeddings` is actually a linear scan over a DashMap.
* **Dialogue LLM layer (~2.9K)** — `llm_dialogue.rs`; the basic
`DialogueGraph`/`DialogueRunner` path is production-wired, the LLM-enhanced layer
is not.
* **NPC isolated subsystem (~1.7K)** — fully isolated AI subsystem with its own
parallel vocabulary; zero imports of canonical AI types.
The interactive workspace map's *AI Pipeline Subsystem Layout* story preset
highlights the active and dormant surfaces side by side.
graph TB
subgraph "AI Pipeline"
Perception[Perception Bus]
Memory[Agent Memory]
Planning[Planning Layer]
Tools[Tool Sandbox]
Validation[Engine Validation]
end
subgraph "Game World"
ECS[ECS World State]
Physics[Physics System]
Nav[Navigation]
end
ECS --> Perception
Perception --> Memory
Memory --> Planning
Planning --> Tools
Tools --> Validation
Validation --> ECS
The perception system provides AI agents with a filtered view of the world:
#![allow(unused)]
fn main() {
use astraweave_ai::perception::*;
#[derive(Component)]
struct AiAgent {
perception_radius: f32,
perception_filter: PerceptionFilter,
}
fn perception_system(
agents: Query<(Entity, &Transform, &AiAgent)>,
percievables: Query<(Entity, &Transform, &Percievable)>,
mut perception_bus: ResMut<PerceptionBus>,
) {
for (agent_entity, agent_transform, agent) in agents.iter() {
let mut percepts = Vec::new();
for (target, target_transform, percievable) in percievables.iter() {
let distance = agent_transform.translation
.distance(target_transform.translation);
if distance <= agent.perception_radius {
percepts.push(Percept {
entity: target,
position: target_transform.translation,
category: percievable.category,
properties: percievable.properties.clone(),
});
}
}
perception_bus.update(agent_entity, percepts);
}
}
}
See Perception Bus for details.
AI agents use LLM-based planning to decide actions:
#![allow(unused)]
fn main() {
use astraweave_ai::planning::*;
fn planning_system(
mut agents: Query<(&mut AiPlanner, &PerceptionState)>,
llm: Res<LlmClient>,
) {
for (mut planner, perception) in agents.iter_mut() {
if planner.needs_replan() {
let context = build_context(perception);
match llm.plan(&context, &planner.available_tools) {
Ok(plan) => planner.set_plan(plan),
Err(e) => planner.fallback_behavior(),
}
}
}
}
}
See Planning Layer for details.
All AI actions go through the tool sandbox for validation:
#![allow(unused)]
fn main() {
use astraweave_ai::tools::*;
fn execute_tool_system(
mut agents: Query<&mut AiPlanner>,
mut tool_executor: ResMut<ToolExecutor>,
validator: Res<ToolValidator>,
) {
for mut planner in agents.iter_mut() {
if let Some(tool_call) = planner.next_action() {
match validator.validate(&tool_call) {
ValidationResult::Valid => {
tool_executor.execute(tool_call);
}
ValidationResult::Invalid(reason) => {
planner.action_failed(reason);
}
}
}
}
}
}
See Tool Sandbox for details.
For deterministic, reactive behaviors:
#![allow(unused)]
fn main() {
use astraweave_behavior::*;
let patrol_tree = BehaviorTree::new(
Selector::new(vec![
Sequence::new(vec![
Condition::new(|ctx| ctx.enemy_visible()),
Action::new(|ctx| ctx.engage_combat()),
]),
Sequence::new(vec![
Condition::new(|ctx| ctx.at_patrol_point()),
Action::new(|ctx| ctx.next_patrol_point()),
]),
Action::new(|ctx| ctx.move_to_patrol_point()),
])
);
}
See Behavior Trees for details.
#![allow(unused)]
fn main() {
fn spawn_companion(world: &mut World) -> Entity {
world.spawn((
Transform::default(),
AiAgent::new()
.with_personality("friendly and helpful")
.with_perception_radius(15.0)
.with_tick_budget(Duration::from_millis(8)),
PerceptionState::default(),
AiPlanner::new(vec![
Tool::move_to(),
Tool::attack(),
Tool::use_item(),
Tool::speak(),
]),
NavAgent::default(),
DialogueCapable::default(),
))
}
}
AI has a strict time budget per simulation tick:
#![allow(unused)]
fn main() {
let config = AiConfig {
tick_budget_ms: 8,
max_concurrent_plans: 4,
plan_cache_duration: Duration::from_secs(1),
fallback_on_timeout: true,
};
}
If planning exceeds the budget, agents use cached plans or fallback behaviors.
AstraWeave uses Ollama for local LLM inference:
ollama serve
ollama pull hermes2-pro-mistral
#![allow(unused)]
fn main() {
let llm_config = LlmConfig {
endpoint: "http://localhost:11434".into(),
model: "hermes2-pro-mistral".into(),
temperature: 0.7,
max_tokens: 256,
timeout: Duration::from_millis(100),
};
}
AstraWeave uses structured tool calling:
#![allow(unused)]
fn main() {
let tools = vec![
ToolDefinition {
name: "move_to",
description: "Move to a target location",
parameters: json!({
"type": "object",
"properties": {
"target": { "type": "array", "items": { "type": "number" } }
}
}),
},
];
let response = llm.generate_with_tools(prompt, &tools).await?;
}
Group AI queries for efficiency:
#![allow(unused)]
fn main() {
let batch = agents.iter()
.filter(|a| a.needs_replan())
.take(4)
.collect::<Vec<_>>();
let results = llm.batch_plan(&batch).await;
}
Cache plans to reduce LLM calls:
#![allow(unused)]
fn main() {
let planner = AiPlanner::new(tools)
.with_plan_cache(Duration::from_secs(2))
.with_context_hash(true);
}
Always define fallbacks:
#![allow(unused)]
fn main() {
impl AiAgent {
fn fallback_behavior(&self) -> Action {
match self.role {
Role::Companion => Action::FollowPlayer,
Role::Guard => Action::Patrol,
Role::Merchant => Action::Idle,
}
}
}
}
Status : Production ReadyCrate : astraweave-ai (requires llm_orchestrator feature)Documentation : See also Complete Implementation Guide
The AIArbiter is a hybrid AI control system that combines instant tactical decisions (GOAP) with deep strategic reasoning (LLM), achieving zero user-facing latency while maintaining LLM-level intelligence.
Traditional game AI faces a dilemma:
Approach Latency Intelligence
Fast AI (GOAP, BT)~100 ns Limited reasoning
Smart AI (LLM)13-21 seconds Deep understanding
Players either wait 20 seconds for smart AI or get immediate but shallow responses.
The arbiter provides zero user-facing latency by:
Instant GOAP control - Returns tactical actions in 101.7 ns
Background LLM planning - Generates strategic plans asynchronously
Seamless transitions - Switches to LLM plans when ready
Non-blocking polling - Checks LLM completion in 104.7 ns
Operation Latency Target Speedup
GOAP update 101.7 ns 100 µs 982×
LLM polling 575 ns 50 µs 86×
Mode transition 221.9 ns 10 µs 45×
Full 3-step cycle 313.7 ns — —
Agents Overhead Frame Budget Status
1,000 101.7 µs 0.6% ✅
10,000 1.02 ms 6.1% ✅
50,000 5.09 ms 30.5% ⚠️
#![allow(unused)]
fn main() {
use astraweave_ai::{AIArbiter, LlmExecutor, GoapOrchestrator, RuleOrchestrator};
use std::sync::Arc;
// Create arbiter
let llm_orch = Arc::new(LlmOrchestrator::new(/* config */));
let runtime = tokio::runtime::Handle::current();
let llm_executor = LlmExecutor::new(llm_orch, runtime);
let goap = Box::new(GoapOrchestrator::new());
let bt = Box::new(RuleOrchestrator);
let mut arbiter = AIArbiter::new(llm_executor, goap, bt);
// Game loop
loop {
let snapshot = build_world_snapshot(/* ... */);
let action = arbiter.update(&snapshot); // 101.7 ns
execute_action(action);
}
}
┌─────────────────────────────────────────────────────┐
│ AIArbiter │
│ (Orchestration Layer - 101.7 ns overhead) │
└────┬────────────────────┬────────────────────┬──────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ GOAP │ │ Qwen3-8B │ │ BT │
│ (3-5 ns) │ │ (13-21s async)│ │ Fallback │
└──────────┘ └──────────────┘ └──────────┘
┌──────────────┐
│ GOAP │ ◄─────────┐
│ (Instant AI) │ │
└───────┬──────┘ │
│ │
│ LLM ready │ Plan exhausted
│ │
┌───────▼──────────┐ │
│ ExecutingLLM │───────┘
│ (Step-by-step) │
└──────────────────┘
│
│ Empty plan
▼
┌──────────────┐
│ BehaviorTree │
│ (Fallback) │
└──────────────┘
#![allow(unused)]
fn main() {
pub struct AIArbiter { /* ... */ }
impl AIArbiter {
/// Create new arbiter in GOAP mode
pub fn new(
llm_executor: LlmExecutor,
goap: Box<dyn Orchestrator>,
bt: Box<dyn Orchestrator>,
) -> Self;
/// Set LLM request cooldown (default: 15s)
pub fn with_llm_cooldown(self, cooldown: f32) -> Self;
/// Main control loop - call every frame
pub fn update(&mut self, snap: &WorldSnapshot) -> ActionStep;
/// Get current mode
pub fn mode(&self) -> AIControlMode;
/// Check if LLM task is active
pub fn is_llm_active(&self) -> bool;
/// Get performance metrics
pub fn metrics(&self) -> (
usize, // mode_transitions
usize, // llm_requests
usize, // llm_successes
usize, // llm_failures
usize, // goap_actions
usize, // llm_steps_executed
);
}
}
#![allow(unused)]
fn main() {
pub enum AIControlMode {
GOAP, // Fast tactical mode
ExecutingLLM { step_index: usize }, // Executing LLM plan
BehaviorTree, // Emergency fallback
}
}
#![allow(unused)]
fn main() {
pub struct AIAgent {
arbiter: AIArbiter,
}
impl AIAgent {
pub fn update(&mut self, snap: &WorldSnapshot) -> ActionStep {
self.arbiter.update(snap)
}
}
}
#![allow(unused)]
fn main() {
// Create once, clone for each agent
let base_executor = LlmExecutor::new(llm_orch, runtime);
let agents: Vec<AIAgent> = (0..100)
.map(|_| AIAgent::new(base_executor.clone()))
.collect();
}
#![allow(unused)]
fn main() {
// Aggressive (more LLM requests)
let arbiter = AIArbiter::new(executor, goap, bt)
.with_llm_cooldown(5.0);
// Passive (fewer LLM requests)
let arbiter = AIArbiter::new(executor, goap, bt)
.with_llm_cooldown(30.0);
}
#![allow(unused)]
fn main() {
let (transitions, requests, successes, failures, goap_actions, llm_steps) =
arbiter.metrics();
let success_rate = 100.0 * successes as f64 / requests as f64;
if success_rate < 50.0 {
warn!("LLM success rate low: {:.1}%", success_rate);
}
}
#![allow(unused)]
fn main() {
match arbiter.mode() {
AIControlMode::GOAP => {
ui.show_status("Tactical Mode");
}
AIControlMode::ExecutingLLM { step_index } => {
ui.show_status(&format!("Strategic Step {}", step_index));
ui.show_indicator("LLM Active");
}
AIControlMode::BehaviorTree => {
ui.show_warning("Fallback Mode");
}
}
}
The LLM cooldown controls how frequently the arbiter requests new strategic plans:
Cooldown Use Case
5s Aggressive - Frequent strategic updates
15s Default - Balanced performance
30s Passive - Reduce LLM costs
0s Immediate - Testing only
#![allow(unused)]
fn main() {
let arbiter = AIArbiter::new(executor, goap, bt)
.with_llm_cooldown(15.0); // Default
}
Symptoms : is_llm_active() always true, never transitions to ExecutingLLM
Causes :
Ollama not running
Model not loaded
Network issues
Fix :
# Verify Ollama is running
ollama list
# Test model directly
ollama run qwen3:8b
Symptoms : llm_failures > 50% of requests
Causes :
Model quality issues
Bad prompts
Timeout too short
Fix :
#![allow(unused)]
fn main() {
// Increase cooldown to reduce impact
let arbiter = AIArbiter::new(executor, goap, bt)
.with_llm_cooldown(30.0);
}
Symptoms : Same action repeated, step_index doesn’t advance
Causes :
Plan has duplicate steps
Plan too long
Fix : Validate plan length before execution:
#![allow(unused)]
fn main() {
if plan.steps.len() > 50 {
warn!("LLM plan too long: {} steps", plan.steps.len());
}
}
# GOAP-only mode
cargo run -p hello_companion --release
# Arbiter mode (GOAP + Qwen3-8B)
cargo run -p hello_companion --release --features llm_orchestrator -- --arbiter
Expected output :
Frame 0: MoveTo { x: 5, y: 5 } (GOAP)
Frame 1: TakeCover { position: Some((3, 2)) } (GOAP)
[INFO] LLM plan ready: 3 steps
Frame 3: MoveTo { x: 4, y: 0 } (ExecutingLLM[step 1])
Frame 4: TakeCover { position: Some((4, 1)) } (ExecutingLLM[step 2])
Frame 5: Attack { target: 1 } (ExecutingLLM[step 3])
[INFO] Plan exhausted, returning to GOAP
Frame 6: MoveTo { x: 5, y: 5 } (GOAP)
The AstraWeave physics system provides comprehensive 3D physics simulation through integration with Rapier , a high-performance physics engine written in Rust.
The physics system runs at a fixed 60Hz tick rate to ensure deterministic simulation.
Key features:
Rigid Body Dynamics - Full 3D rigid body simulation
Character Controllers - Player and NPC movement with collision
Collision Detection - Broad and narrow phase collision
Spatial Queries - Raycasting, shape casting, overlap tests
Joints & Constraints - Connect bodies with various joint types
Continuous Collision Detection - Prevents tunneling for fast objects
graph TB
subgraph Physics Pipeline
A[Collect Inputs] --> B[Broad Phase]
B --> C[Narrow Phase]
C --> D[Constraint Solver]
D --> E[Position Integration]
E --> F[Sync to ECS]
end
G[ECS World] --> A
F --> G
Represents a physics-simulated body:
#![allow(unused)]
fn main() {
use astraweave_physics::{RigidBody, RigidBodyType};
let body = RigidBody {
body_type: RigidBodyType::Dynamic,
mass: 1.0,
linear_damping: 0.1,
angular_damping: 0.1,
gravity_scale: 1.0,
..Default::default()
};
}
Body types:
Dynamic - Fully simulated, responds to forces
Static - Immovable, used for environment
Kinematic - Moved by code, pushes dynamic bodies
Defines collision shapes:
#![allow(unused)]
fn main() {
use astraweave_physics::{Collider, ColliderShape};
let collider = Collider {
shape: ColliderShape::Box { half_extents: Vec3::new(1.0, 1.0, 1.0) },
friction: 0.5,
restitution: 0.3,
sensor: false,
..Default::default()
};
}
Available shapes:
Box - Axis-aligned box
Sphere - Perfect sphere
Capsule - Cylinder with spherical caps
Cylinder - Circular cylinder
ConvexHull - Convex mesh
TriMesh - Triangle mesh (static only)
HeightField - Terrain heightmap
Handles player/NPC movement:
#![allow(unused)]
fn main() {
use astraweave_physics::CharacterController;
let controller = CharacterController {
height: 1.8,
radius: 0.3,
step_height: 0.3,
max_slope: 45.0_f32.to_radians(),
..Default::default()
};
}
#![allow(unused)]
fn main() {
use astraweave_physics::{RaycastQuery, RaycastHit};
let query = RaycastQuery {
origin: Vec3::new(0.0, 5.0, 0.0),
direction: Vec3::NEG_Y,
max_distance: 100.0,
filter: CollisionFilter::default(),
};
if let Some(hit) = physics.raycast(&query) {
println!("Hit entity {:?} at distance {}", hit.entity, hit.distance);
}
}
#![allow(unused)]
fn main() {
use astraweave_physics::{ShapeCastQuery, ColliderShape};
let query = ShapeCastQuery {
shape: ColliderShape::Sphere { radius: 0.5 },
origin: start_pos,
direction: velocity.normalize(),
max_distance: velocity.length(),
};
let hits = physics.shape_cast(&query);
}
#![allow(unused)]
fn main() {
use astraweave_physics::OverlapQuery;
let query = OverlapQuery {
shape: ColliderShape::Sphere { radius: 5.0 },
position: explosion_center,
filter: CollisionFilter::default(),
};
for entity in physics.overlap(&query) {
apply_explosion_damage(entity);
}
}
Control which objects can collide:
#![allow(unused)]
fn main() {
use astraweave_physics::{CollisionFilter, CollisionGroup};
let player_filter = CollisionFilter {
membership: CollisionGroup::PLAYER,
filter: CollisionGroup::WORLD | CollisionGroup::ENEMY | CollisionGroup::PROJECTILE,
};
}
The physics system uses a spatial hash for broad-phase acceleration:
#![allow(unused)]
fn main() {
use astraweave_physics::SpatialHash;
let spatial = SpatialHash::new(10.0);
spatial.insert(entity, aabb);
let nearby = spatial.query_aabb(&query_aabb);
}
For large simulations, physics can run asynchronously:
#![allow(unused)]
fn main() {
use astraweave_physics::AsyncPhysicsScheduler;
let scheduler = AsyncPhysicsScheduler::new(4);
scheduler.step(&mut physics_world, delta_time).await;
}
Physics components sync automatically with ECS transforms:
#![allow(unused)]
fn main() {
fn physics_sync_system(
query: Query<(&mut Transform, &RigidBodyHandle)>,
physics: Res<PhysicsWorld>,
) {
for (mut transform, handle) in query.iter_mut() {
if let Some(body) = physics.get_body(*handle) {
transform.translation = body.position();
transform.rotation = body.rotation();
}
}
}
}
AstraWeave’s rendering system is built on top of WGPU, providing a modern, high-performance graphics pipeline with support for physically-based rendering (PBR), advanced lighting techniques, and post-processing effects.
The rendering system follows a modular architecture with clear separation of concerns:
graph TD
A[Render Graph] --> B[Material System]
A --> C[Lighting System]
A --> D[Post-Processing]
B --> E[Texture Streaming]
C --> F[Clustered Forward]
D --> G[Bloom/Tonemap]
E --> H[GPU Upload]
F --> I[Light Culling]
Render Graph : Declarative frame composition with automatic resource barriers
Material System : PBR materials with metallic-roughness workflow
Lighting : Clustered forward rendering supporting thousands of dynamic lights
Texture Streaming : Virtual texture system with priority-based loading
Post-Processing : Modular effects stack (bloom, tonemap, FXAA, etc.)
The rendering system is designed to scale from integrated graphics to high-end GPUs, with automatic quality adaptation based on detected hardware capabilities.
AstraWeave uses WGPU as its graphics abstraction layer, providing cross-platform support for Vulkan, Metal, DirectX 12, and WebGPU.
#![allow(unused)]
fn main() {
use astraweave_render::{RenderContext, RenderConfig};
// Initialize the renderer
let config = RenderConfig {
width: 1920,
height: 1080,
vsync: true,
msaa_samples: 4,
..Default::default()
};
let render_ctx = RenderContext::new(&window, config).await?;
}
The render context manages the WGPU device and queue lifecycle:
#![allow(unused)]
fn main() {
// Access device for resource creation
let device = render_ctx.device();
let queue = render_ctx.queue();
// Create a texture
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("game_texture"),
size: wgpu::Extent3d {
width: 1024,
height: 1024,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
}
AstraWeave implements a complete PBR pipeline based on the metallic-roughness workflow, closely following theglTF 2.0 specification.
#![allow(unused)]
fn main() {
use astraweave_render::material::{Material, PbrMaterial};
let material = PbrMaterial {
base_color: [1.0, 1.0, 1.0, 1.0],
base_color_texture: Some(texture_handle),
metallic: 0.0,
roughness: 0.5,
metallic_roughness_texture: Some(mr_texture),
normal_texture: Some(normal_map),
emissive: [0.0, 0.0, 0.0],
emissive_texture: None,
occlusion_texture: Some(ao_map),
alpha_mode: AlphaMode::Opaque,
double_sided: false,
};
// Register material with renderer
let material_id = render_ctx.register_material(material);
}
Property Type Description
base_color[f32; 4]RGB color with alpha channel
metallicf32Metallic factor (0 = dielectric, 1 = metal)
roughnessf32Surface roughness (0 = smooth, 1 = rough)
emissive[f32; 3]Emissive color (HDR)
normal_textureOption<Handle>Tangent-space normal map
occlusion_textureOption<Handle>Ambient occlusion map
Use texture packing to reduce memory bandwidth: combine metallic (B channel), roughness (G channel), and occlusion (R channel) into a single texture.
The PBR shader implements a Cook-Torrance BRDF with:
Diffuse : Lambertian diffuse term
Specular : GGX/Trowbridge-Reitz normal distribution
Fresnel : Schlick’s approximation
Geometry : Smith’s shadowing-masking function
#![allow(unused)]
fn main() {
// BRDF calculation (shader pseudo-code)
fn pbr_brdf(
N: vec3<f32>, // Normal
V: vec3<f32>, // View direction
L: vec3<f32>, // Light direction
F0: vec3<f32>, // Fresnel reflectance at 0°
roughness: f32,
metallic: f32,
) -> vec3<f32> {
let H = normalize(V + L);
let NdotH = max(dot(N, H), 0.0);
let NdotV = max(dot(N, V), 0.0);
let NdotL = max(dot(N, L), 0.0);
// Distribution term (GGX)
let D = distribution_ggx(NdotH, roughness);
// Geometry term (Smith)
let G = geometry_smith(NdotV, NdotL, roughness);
// Fresnel term (Schlick)
let F = fresnel_schlick(max(dot(H, V), 0.0), F0);
// Cook-Torrance BRDF
let specular = (D * G * F) / (4.0 * NdotV * NdotL + 0.0001);
let kD = (vec3(1.0) - F) * (1.0 - metallic);
let diffuse = kD * base_color.rgb / PI;
return (diffuse + specular) * radiance * NdotL;
}
}
Clustered forward rendering divides the view frustum into a 3D grid of clusters, assigning lights to clusters for efficient culling.
#![allow(unused)]
fn main() {
use astraweave_render::lighting::{ClusteredLightingConfig, LightCluster};
let cluster_config = ClusteredLightingConfig {
tile_size_x: 16,
tile_size_y: 16,
z_slices: 24,
max_lights_per_cluster: 256,
};
render_ctx.configure_clustered_lighting(cluster_config);
}
graph LR
A[View Frustum] --> B[XY Tiling]
B --> C[Z Slicing]
C --> D[Cluster Grid]
D --> E[Light Assignment]
E --> F[Per-Cluster Light Lists]
The frustum is divided into:
XY tiles : Screen-space subdivision (typically 16x16 pixels)
Z slices : Exponential depth slices for better near-field distribution
#![allow(unused)]
fn main() {
use astraweave_render::lighting::{PointLight, SpotLight, DirectionalLight};
// Point light
let point_light = PointLight {
position: [0.0, 5.0, 0.0],
color: [1.0, 0.9, 0.8],
intensity: 100.0,
radius: 10.0,
falloff: 2.0, // Inverse square falloff
};
render_ctx.add_light(point_light);
// Directional light (sun)
let sun = DirectionalLight {
direction: [-0.3, -1.0, -0.5],
color: [1.0, 0.95, 0.9],
intensity: 5.0,
cast_shadows: true,
shadow_cascade_count: 4,
};
render_ctx.set_directional_light(sun);
}
The system performs GPU-based light culling using a compute shader:
#![allow(unused)]
fn main() {
// Compute shader for light culling (WGSL)
@compute @workgroup_size(16, 16, 1)
fn cull_lights(
@builtin(global_invocation_id) global_id: vec3<u32>,
) {
let cluster_id = compute_cluster_id(global_id);
let cluster_aabb = get_cluster_bounds(cluster_id);
var light_count: u32 = 0u;
for (var i = 0u; i < light_buffer.count; i++) {
let light = light_buffer.lights[i];
if (sphere_aabb_intersection(light.position, light.radius, cluster_aabb)) {
light_indices[cluster_id * MAX_LIGHTS + light_count] = i;
light_count++;
}
}
cluster_light_counts[cluster_id] = light_count;
}
}
Clustered forward rendering requires compute shader support. Fallback to traditional forward rendering is provided for older hardware.
AstraWeave provides a flexible post-processing stack with composable effects.
#![allow(unused)]
fn main() {
use astraweave_render::post::{PostProcessStack, BloomEffect, TonemapEffect, FxaaEffect};
let mut post_stack = PostProcessStack::new();
// Add bloom
post_stack.add_effect(BloomEffect {
threshold: 1.0,
intensity: 0.5,
blur_passes: 5,
});
// Add tonemapping
post_stack.add_effect(TonemapEffect {
exposure: 1.0,
operator: TonemapOperator::AcesFilmic,
});
// Add anti-aliasing
post_stack.add_effect(FxaaEffect {
quality: FxaaQuality::High,
});
render_ctx.set_post_processing(post_stack);
}
Effect Description Performance Impact
Bloom HDR glow effect Medium
Tonemap HDR to LDR mapping Low
FXAA Fast approximate anti-aliasing Low
SMAA Subpixel morphological anti-aliasing Medium
Depth of Field Bokeh blur based on depth High
Motion Blur Velocity-based blur Medium
Color Grading LUT-based color correction Low
#![allow(unused)]
fn main() {
use astraweave_render::post::{PostEffect, PostEffectContext};
struct CustomVignetteEffect {
intensity: f32,
radius: f32,
}
impl PostEffect for CustomVignetteEffect {
fn apply(&self, ctx: &mut PostEffectContext) -> Result<()> {
let shader = ctx.load_shader("vignette.wgsl")?;
ctx.bind_uniform("intensity", &self.intensity);
ctx.bind_uniform("radius", &self.radius);
ctx.bind_texture(0, ctx.input_texture());
ctx.dispatch_fullscreen(shader)?;
Ok(())
}
}
}
The texture streaming system manages GPU memory efficiently by loading textures on-demand with priority-based eviction.
#![allow(unused)]
fn main() {
use astraweave_render::texture::{TextureStreamingConfig, VirtualTexture};
let streaming_config = TextureStreamingConfig {
budget_mb: 512,
min_mip_level: 0,
max_mip_level: 12,
load_bias: 0.0,
anisotropy: 16,
};
render_ctx.configure_texture_streaming(streaming_config);
}
#![allow(unused)]
fn main() {
use astraweave_render::texture::TextureLoader;
// Load texture with streaming
let texture = TextureLoader::new()
.with_streaming(true)
.with_mip_levels(8)
.with_compression(TextureCompression::BC7)
.load("assets/textures/ground_albedo.png")?;
// Set priority (higher = load sooner)
texture.set_priority(1.0);
}
The system automatically selects mip levels based on:
Distance to camera
Screen coverage
Available GPU memory
User-defined priority
graph TD
A[Texture Request] --> B{In Budget?}
B -->|Yes| C[Load Full Resolution]
B -->|No| D[Calculate Required Mip]
D --> E[Load Mip Level]
E --> F{Budget Exceeded?}
F -->|Yes| G[Evict Low Priority]
F -->|No| H[Complete]
G --> H
C --> H
Pre-generate mip chains offline and compress textures to reduce load times. AstraWeave supports BC1-BC7, ASTC, and ETC2 compression formats.
#![allow(unused)]
fn main() {
use astraweave_render::profiling::GpuProfiler;
let profiler = GpuProfiler::new(&render_ctx);
profiler.begin_frame();
{
profiler.begin_scope("Shadow Pass");
render_shadows(&mut render_ctx);
profiler.end_scope();
profiler.begin_scope("Main Pass");
render_main_scene(&mut render_ctx);
profiler.end_scope();
profiler.begin_scope("Post Processing");
apply_post_effects(&mut render_ctx);
profiler.end_scope();
}
profiler.end_frame();
// Print timing report
println!("{}", profiler.report());
}
Use GPU instancing for rendering many identical meshes:
#![allow(unused)]
fn main() {
use astraweave_render::mesh::InstancedMesh;
let instances: Vec<InstanceData> = trees
.iter()
.map(|tree| InstanceData {
transform: tree.transform.matrix(),
color: tree.color,
})
.collect();
render_ctx.draw_instanced(tree_mesh, &instances);
}
#![allow(unused)]
fn main() {
use astraweave_render::culling::OcclusionCulling;
let mut occlusion = OcclusionCulling::new(&render_ctx);
// First pass: render occluders
occlusion.render_occluders(&occluder_meshes);
// Second pass: test visibility
let visible_objects = occlusion.test_visibility(&all_objects);
// Third pass: render only visible objects
render_objects(&visible_objects);
}
use astraweave_camera::{CameraProducer, FreeFly};
use astraweave_render::Renderer;
use winit::event_loop::EventLoop;
fn main() -> Result<()> {
let event_loop = EventLoop::new();
let window = Window::new(&event_loop)?;
let mut renderer = Renderer::new(&window).await?;
let mut camera = FreeFly {
position: Vec3::new(0.0, 5.0, 10.0),
yaw: 0.0,
pitch: 0.0,
fovy: 60_f32.to_radians(),
aspect: 16.0 / 9.0,
znear: 0.1,
zfar: 1000.0,
};
event_loop.run(move |event, _, control_flow| {
match event {
Event::RedrawRequested(_) => {
// Update renderer's camera state via the canonical upload path.
renderer.update_view(&camera.to_render_view());
// Render the frame
renderer.render().unwrap();
// Present
frame.present();
}
_ => {}
}
});
}
AstraWeave has two production camera producers — FreeFly for engine-runtime
use cases and OrbitCamera for the editor — and each lives in the crate that
owns its primary use case:
FreeFly is defined in astraweave-camera and used by every example
crate, the cinematics renderer path, and any application embedding the
engine. The example above uses FreeFly because the typical rendering-loop
consumer is a runtime application.
OrbitCamera is defined in tools/aw_editor/src/viewport/camera.rs
and used exclusively by the editor’s viewport. It implements
CameraProducer (added in Unified Camera sub-phase C.4), so the upload
contract is identical: renderer.update_view(&camera.to_render_view()).
Its surface adds editor-specific affordances — picking, frustum extraction,
bookmark restore — that the runtime engine doesn’t need.
The renderer consumes RenderView exclusively (per
CAMERA_CONVENTIONS.md §2.9); it doesn’t know which producer created the
view. New engine-runtime producers (Follow, Cinematic, Debug) belong in
astraweave-camera; new editor-only producers belong in tools/aw_editor/.
Cinematics camera state consolidated to a single canonical type during
the Unified Camera campaign’s C.7 chapter: astraweave_cinematics::CameraKey,
which carries a keyframe’s timestamp (t), world position (pos),
look-at target (look_at), and field of view (fov_deg, degrees). It
provides lerp (interpolation between adjacent keyframes) and sanitize
(boundary hardening: clamps fov_deg to [10°, 170°]; resolves a
degenerate look_at == pos to canonical +X forward). The cinematics
crate has no glam dependency by design — pos/look_at are
(f32, f32, f32) tuples — keeping it a self-contained data-layer crate
any crate can depend on without circular-dependency risk.
Cinematics camera state reaches the renderer via
Renderer::tick_cinematics(dt, &mut camera), which advances a loaded
Timeline through its Sequencer, dispatches CameraKey events to
apply_camera_key (which sanitizes defensively, then converts each key
into a FreeFly camera — fov_deg becomes fovy in radians at this
producer boundary), and the FreeFly produces a RenderView through the
canonical CameraProducer contract. So cinematics flows through the same
canonical path every camera uses — CameraKey → FreeFly → RenderView → Renderer::update_view — with no bespoke cinematics renderer API (per
CAMERA_CONVENTIONS.md §2.9).
Before C.7, three parallel cinematics camera systems coexisted with no
conversion functions between them. The chapter consolidated them
per-system: gameplay cutscenes (Cue::CameraTo) evolved their fields to
look_at + fov_deg and now emit canonical CameraKey events via the
CutsceneTickEvent enum (C.7.A); examples/cutscene_render_demo rewrote
its tick loop onto tick_cinematics, becoming the first production caller
(C.7.B); the canonical key was hardened with sanitize at the
apply_camera_key boundary (C.7.D); and the editor’s parallel
CameraKeyframe type was retired entirely into CameraKey (C.7.C), with
its UI-state-only roll feature dropped.
One cinematics-camera surface remains outside the consolidation as a
deliberate deferral : the dev-only “Simple Cinematics” panel in
astraweave-ui. It already uses the canonical CameraKey type (it
loads/saves Timeline JSON and steps a Sequencer), but has no renderer
connection — it’s a timeline-inspection debugging tool, not a viewport-wired
authoring surface. It’s not a parallel type (it uses canonical types); its
remaining work is tool-integration, a different concern from camera
consolidation. Deferred to post-campaign cleanup.
#![allow(unused)]
fn main() {
// Update material properties at runtime
let material = render_ctx.get_material_mut(material_id)?;
material.base_color = [1.0, 0.0, 0.0, 1.0]; // Change to red
material.roughness = 0.8; // Make rougher
render_ctx.mark_material_dirty(material_id);
}
For complete API documentation, see:
This page was rewritten on 2026-05-15 to reflect the engineering reality surfaced by the
architecture trace campaign. A prior version (added in commit 28bc94f21,
2025-09-08, by an automated documentation pass) described an extensive `AudioConfig` /
`AudioBackend` / `MusicManager` / `SfxManager` / `AudioMixer` API surface that
**does not exist in the codebase**. The actual public surface is small (~30 lines of
re-exports). See the trace below for evidence-grounded detail.
The astraweave-audio crate is a focused facade over rodio / cpal. The lib.rs
re-exports are:
AudioEngine — owns the rodio OutputStream chain. !Send + !Sync because
cpal::Stream is non-Send-Sync across all platforms. Consequence: cannot be an
ECS Resource, cannot be wrapped in Arc<RwLock<_>> for cross-thread sharing.
Consumers hold it directly.
DialoguePlayer — voice-line playback.
VoiceBank — voice-line collection.
EmitterId — u64, with no allocator and no sentinel. Collisions silently
merge SpatialSinks (documented hazard, audio.md §6 trap).
ListenerPose — ear-position pose for spatial panning.
AudioEngine mixes through 5 buses : master, music, ambient, voice, SFX.
The lib.rs:8 doc-comment that advertises “4-bus mixer (master, music, SFX, voice)”
is stale — the ambient bus was added in commit 745c100a8 alongside biome material
work. The bus count is documented in audio.md §1 as a status note.
The visual editor’s AudioPanel exposes ~25 audio control knobs (HRTF, Doppler,
distance model, reverb, crossfade duration, shuffle/loop). 10+ of these have
bodyless or comment-only match arms in tools/aw_editor/src/audio_bridge.rs:165-205 —
they are forward-design UI placeholders that update editor state but do not reach
the runtime audio engine. Tracked as Q22 in ARCHITECTURE_MAP.md §14.
Specific documented no-ops:
pan_mode field is stored and updated but never read by any other method;
spatial sinks already created continue using rodio’s spatial panning regardless.
HRTF / distance-model / reverb knobs accept user input that the engine does not
consume.
Per audio.md §6 and ARCHITECTURE_MAP.md §7.1, the prior version of this page —
along with docs/src/api/audio.md — referenced a comprehensive audio
backend / manager API. Treat any older references to AudioConfig,
AudioBackend, BackendType, AudioListener, SpatialSound, AttenuationModel,
ReverbZone, AudioOcclusion, MusicManager, MusicLayer, SfxManager,
SoundPool, AudioMixer, or Bus as historical aspirational documentation. Those
types are not in astraweave-audio/src/lib.rs re-exports.
Need File
Public re-exports astraweave-audio/src/lib.rs
Audio engine implementation astraweave-audio/src/
Editor bridge (10+ no-op action variants) tools/aw_editor/src/audio_bridge.rs:165-205
Editor audio panel UI tools/aw_editor/src/panels/audio_panel.rs
audio.md — full audio-system trace (file map, conflict map, decision
log, invariants, open questions).
ARCHITECTURE_MAP.md §7.1 — documentation-hazard inventory.
Interactive workspace map — select astraweave-audio to see the
panel detail with dormancy evidence and the §4.3 silent-failure shapes.
AstraWeave’s navigation system provides robust pathfinding and agent movement with support for dynamic navmesh generation, A* pathfinding, local steering, and obstacle avoidance.
The navigation system consists of multiple layers working together to enable intelligent agent movement.
graph TD
A[Navigation System] --> B[Navmesh Generation]
A --> C[Pathfinding]
A --> D[Steering]
B --> E[Voxelization]
B --> F[Region Building]
C --> G[A* Search]
D --> H[Obstacle Avoidance]
D --> I[Local Movement]
Navmesh Generation : Automatic navigation mesh creation from level geometry
Pathfinding : A* algorithm with hierarchical path optimization
Steering : Local navigation with obstacle avoidance and crowd simulation
Dynamic Updates : Runtime navmesh modification for destructible environments
Off-Mesh Links : Manual connections for jumps, ladders, and teleports
The navigation system is based on Recast & Detour algorithms, providing industry-standard navmesh generation and pathfinding.
#![allow(unused)]
fn main() {
use astraweave_nav::{NavmeshBuilder, NavmeshConfig};
let config = NavmeshConfig {
cell_size: 0.3, // XZ plane cell size
cell_height: 0.2, // Y axis cell size
agent_height: 2.0, // Agent height in meters
agent_radius: 0.6, // Agent radius in meters
agent_max_climb: 0.9, // Max step height
agent_max_slope: 45.0, // Max walkable slope in degrees
region_min_size: 8.0, // Min region area
region_merge_size: 20.0, // Region merge threshold
edge_max_len: 12.0, // Max edge length
edge_max_error: 1.3, // Edge simplification error
verts_per_poly: 6, // Vertices per polygon
detail_sample_dist: 6.0, // Detail mesh sample distance
detail_sample_max_error: 1.0, // Detail mesh error
};
let mut builder = NavmeshBuilder::new(config);
}
#![allow(unused)]
fn main() {
use astraweave_nav::geometry::InputGeometry;
// Collect level geometry
let mut geometry = InputGeometry::new();
// Add static meshes
for mesh in level.static_meshes() {
geometry.add_mesh(mesh.vertices(), mesh.indices());
}
// Mark areas (walkable, non-walkable, water, etc.)
geometry.mark_area(
bounding_box,
AreaType::NonWalkable,
);
// Build navmesh
let navmesh = builder.build(&geometry)?;
}
#![allow(unused)]
fn main() {
use astraweave_nav::NavmeshConfig;
// Humanoid character (default)
let human_config = NavmeshConfig::humanoid();
// Small creature (rat, dog)
let small_config = NavmeshConfig::small_creature();
// Large creature (ogre, vehicle)
let large_config = NavmeshConfig::large_creature();
// Flying unit
let flying_config = NavmeshConfig::flying();
// Custom configuration
let custom_config = NavmeshConfig {
agent_height: 1.5,
agent_radius: 0.4,
agent_max_climb: 0.5,
agent_max_slope: 60.0,
..NavmeshConfig::humanoid()
};
}
Use smaller cell sizes for more detailed navmeshes, but be aware of increased memory usage and build times. A cell size of 0.3m works well for humanoid characters.
#![allow(unused)]
fn main() {
use astraweave_nav::NavmeshLayer;
// Create separate navmeshes for different agent types
let mut nav_system = NavigationSystem::new();
// Layer 0: Infantry
nav_system.add_layer(
NavmeshLayer::new("infantry")
.with_config(NavmeshConfig::humanoid())
.with_geometry(&level_geometry)
);
// Layer 1: Vehicles
nav_system.add_layer(
NavmeshLayer::new("vehicles")
.with_config(NavmeshConfig::large_creature())
.with_geometry(&vehicle_geometry)
);
// Layer 2: Flying
nav_system.add_layer(
NavmeshLayer::new("flying")
.with_config(NavmeshConfig::flying())
.with_geometry(&flying_geometry)
);
}
#![allow(unused)]
fn main() {
use astraweave_nav::{Pathfinder, PathQuery};
let pathfinder = Pathfinder::new(&navmesh);
// Find path from start to end
let query = PathQuery {
start: [10.0, 0.0, 5.0],
end: [50.0, 0.0, 30.0],
extents: [2.0, 4.0, 2.0], // Search extents for nearest poly
filter: None,
};
let path = pathfinder.find_path(query)?;
// Path is a Vec<[f32; 3]> of waypoints
for waypoint in path.waypoints() {
println!("Waypoint: {:?}", waypoint);
}
}
#![allow(unused)]
fn main() {
use astraweave_nav::PathSmoothingOptions;
let smoothing = PathSmoothingOptions {
max_iterations: 100,
max_smooth_distance: 0.5,
};
let smooth_path = pathfinder.smooth_path(&path, smoothing)?;
}
#![allow(unused)]
fn main() {
use astraweave_nav::filter::{PathFilter, AreaFlags};
// Create filter for specific area types
let mut filter = PathFilter::new();
filter.set_area_cost(AreaType::Road, 1.0); // Prefer roads
filter.set_area_cost(AreaType::Grass, 2.0); // Neutral
filter.set_area_cost(AreaType::Water, 10.0); // Avoid water
filter.set_area_cost(AreaType::Lava, f32::MAX); // Never path through lava
filter.exclude_flags(AreaFlags::DISABLED);
let query = PathQuery {
start: start_pos,
end: end_pos,
extents: [2.0, 4.0, 2.0],
filter: Some(filter),
};
let path = pathfinder.find_path(query)?;
}
#![allow(unused)]
fn main() {
use astraweave_nav::hierarchical::HierarchicalPathfinder;
// Create hierarchical graph for long-distance paths
let hpa_pathfinder = HierarchicalPathfinder::new(&navmesh, 32.0); // 32m clusters
// Fast long-distance pathfinding
let long_path = hpa_pathfinder.find_path(query)?;
}
graph TD
A[Start] --> B[Find Nearest Poly]
B --> C[A* Search]
C --> D{Path Found?}
D -->|Yes| E[Smooth Path]
D -->|No| F[Partial Path]
E --> G[String Pulling]
G --> H[Return Waypoints]
F --> H
#![allow(unused)]
fn main() {
// Allow partial paths when destination unreachable
let query = PathQuery {
start: start_pos,
end: unreachable_pos,
extents: [2.0, 4.0, 2.0],
filter: None,
};
let result = pathfinder.find_path(query)?;
if result.is_partial() {
println!("Couldn't reach destination, got {} waypoints", result.waypoints().len());
// Path goes as close as possible to target
} else {
println!("Complete path found!");
}
}
#![allow(unused)]
fn main() {
use astraweave_nav::agent::{NavAgent, AgentParams};
// Create navigation agent
let agent_params = AgentParams {
radius: 0.6,
height: 2.0,
max_acceleration: 8.0,
max_speed: 5.5,
collision_query_range: 2.0,
path_optimization_range: 5.0,
separation_weight: 2.0,
..Default::default()
};
let mut agent = NavAgent::new(agent_params);
// Set destination
agent.set_destination([50.0, 0.0, 30.0]);
// Update each frame
let delta_time = 0.016; // 60 FPS
agent.update(delta_time, &pathfinder);
// Get current velocity and position
let velocity = agent.velocity();
let position = agent.position();
// Apply to entity transform
entity.position = position;
entity.rotation = Quat::from_rotation_y(velocity.x.atan2(velocity.z));
}
#![allow(unused)]
fn main() {
use astraweave_nav::agent::AgentState;
match agent.state() {
AgentState::Idle => {
// Agent has no destination
}
AgentState::Moving => {
// Agent is moving toward destination
}
AgentState::Arrived => {
// Agent reached destination
agent.stop();
}
AgentState::Stuck => {
// Agent couldn't make progress
agent.set_destination(fallback_position);
}
}
}
#![allow(unused)]
fn main() {
// Dynamic speed based on terrain
let current_area = navmesh.get_area_at(agent.position());
let speed_multiplier = match current_area {
AreaType::Road => 1.2,
AreaType::Grass => 1.0,
AreaType::Water => 0.5,
_ => 1.0,
};
agent.set_max_speed(5.5 * speed_multiplier);
}
#![allow(unused)]
fn main() {
use astraweave_nav::steering::{SteeringBehavior, AvoidanceParams};
let avoidance = AvoidanceParams {
horizon_time: 2.5, // Look-ahead time
obstacle_margin: 0.3, // Extra clearance
max_neighbors: 10, // Max agents to consider
neighbor_dist: 10.0, // Neighbor search radius
separation_weight: 2.0, // Separation strength
alignment_weight: 1.0, // Velocity alignment
cohesion_weight: 1.0, // Group cohesion
};
agent.set_avoidance_params(avoidance);
}
#![allow(unused)]
fn main() {
use astraweave_nav::obstacle::DynamicObstacle;
// Add temporary obstacles
let obstacle = DynamicObstacle::cylinder(
position,
1.5, // radius
2.0, // height
);
nav_system.add_obstacle(obstacle);
// Agents will automatically avoid the obstacle
// Remove when no longer needed
nav_system.remove_obstacle(obstacle.id());
}
#![allow(unused)]
fn main() {
use astraweave_nav::crowd::{CrowdManager, CrowdAgent};
let mut crowd = CrowdManager::new(&navmesh, 100); // Max 100 agents
// Add agents to crowd
let agent_id = crowd.add_agent(CrowdAgent {
position: start_pos,
params: agent_params,
});
// Set target
crowd.set_target(agent_id, end_pos);
// Update all agents efficiently
crowd.update(delta_time);
// Get agent state
let agent = crowd.get_agent(agent_id);
println!("Position: {:?}, Velocity: {:?}", agent.position, agent.velocity);
}
CrowdManager uses spatial hashing and optimized neighbor queries to efficiently simulate hundreds of agents with local avoidance.
Off-mesh links enable navigation across gaps, jumps, ladders, and teleporters.
#![allow(unused)]
fn main() {
use astraweave_nav::offmesh::{OffMeshLink, OffMeshLinkType};
// Jump down
let jump_link = OffMeshLink {
start: [10.0, 5.0, 0.0],
end: [15.0, 0.0, 0.0],
radius: 0.5,
bidirectional: false, // One-way
link_type: OffMeshLinkType::Jump,
cost: 2.0, // Path cost
};
navmesh.add_offmesh_link(jump_link);
// Ladder (bidirectional)
let ladder_link = OffMeshLink {
start: [20.0, 0.0, 0.0],
end: [20.0, 5.0, 0.0],
radius: 0.5,
bidirectional: true,
link_type: OffMeshLinkType::Climb,
cost: 5.0,
};
navmesh.add_offmesh_link(ladder_link);
}
#![allow(unused)]
fn main() {
// Agent detects off-mesh link during pathfinding
if let Some(link) = agent.current_offmesh_link() {
match link.link_type {
OffMeshLinkType::Jump => {
// Play jump animation
animation.play("jump");
// Arc trajectory
let t = link.traversal_progress();
let height = 2.0 * t * (1.0 - t); // Parabolic arc
agent.position.y += height;
}
OffMeshLinkType::Climb => {
// Play climb animation
animation.play("climb_ladder");
}
OffMeshLinkType::Teleport => {
// Instant teleport
agent.position = link.end;
}
}
}
}
#![allow(unused)]
fn main() {
use astraweave_nav::dynamic::{NavmeshModifier, TileCache};
// Create tile cache for dynamic updates
let mut tile_cache = TileCache::new(&navmesh, config);
// Add obstacle (e.g., fallen tree)
let obstacle_id = tile_cache.add_obstacle(
position,
bounding_box,
);
// Rebuild affected tiles
tile_cache.update()?;
// Remove obstacle when destroyed
tile_cache.remove_obstacle(obstacle_id);
tile_cache.update()?;
}
#![allow(unused)]
fn main() {
// Block area temporarily
let blocker_id = nav_system.block_area(
center,
radius,
duration_seconds,
);
// Automatically unblocks after duration
}
#![allow(unused)]
fn main() {
use astraweave_nav::async_pathfinding::AsyncPathfinder;
let async_pathfinder = AsyncPathfinder::new(&navmesh);
// Request path asynchronously
let path_future = async_pathfinder.find_path_async(query);
// Continue gameplay, path calculated in background
// Check if ready
if let Some(path) = path_future.try_get() {
agent.set_path(path);
}
}
#![allow(unused)]
fn main() {
use astraweave_nav::cache::PathCache;
let mut path_cache = PathCache::new(1000); // Cache 1000 paths
// Check cache before pathfinding
let cache_key = PathCache::key(start, end);
if let Some(cached_path) = path_cache.get(&cache_key) {
return cached_path.clone();
}
// Calculate and cache
let path = pathfinder.find_path(query)?;
path_cache.insert(cache_key, path.clone());
}
#![allow(unused)]
fn main() {
// Reduce update frequency for distant agents
let distance_to_player = (agent.position - player.position).length();
let update_interval = if distance_to_player < 20.0 {
0.0 // Every frame
} else if distance_to_player < 50.0 {
0.1 // 10 times per second
} else {
0.5 // 2 times per second
};
if agent.time_since_update() >= update_interval {
agent.update(delta_time, &pathfinder);
}
}
use astraweave_nav::*;
pub struct AICharacter {
agent: NavAgent,
current_path: Option<Path>,
destination: Option<Vec3>,
}
impl AICharacter {
pub fn new(start_position: Vec3) -> Self {
let params = AgentParams {
radius: 0.6,
height: 2.0,
max_speed: 5.0,
max_acceleration: 10.0,
..Default::default()
};
let mut agent = NavAgent::new(params);
agent.set_position([start_position.x, start_position.y, start_position.z]);
Self {
agent,
current_path: None,
destination: None,
}
}
pub fn move_to(&mut self, destination: Vec3, pathfinder: &Pathfinder) -> Result<()> {
let query = PathQuery {
start: self.agent.position(),
end: [destination.x, destination.y, destination.z],
extents: [2.0, 4.0, 2.0],
filter: None,
};
let path = pathfinder.find_path(query)?;
self.current_path = Some(path);
self.destination = Some(destination);
self.agent.set_destination([destination.x, destination.y, destination.z]);
Ok(())
}
pub fn update(&mut self, delta_time: f32, pathfinder: &Pathfinder) {
self.agent.update(delta_time, pathfinder);
// Check if arrived
if self.agent.state() == AgentState::Arrived {
self.current_path = None;
self.destination = None;
}
}
pub fn position(&self) -> Vec3 {
let pos = self.agent.position();
Vec3::new(pos[0], pos[1], pos[2])
}
pub fn velocity(&self) -> Vec3 {
let vel = self.agent.velocity();
Vec3::new(vel[0], vel[1], vel[2])
}
}
// Usage
fn main() -> Result<()> {
let navmesh = build_navmesh()?;
let pathfinder = Pathfinder::new(&navmesh);
let mut character = AICharacter::new(Vec3::new(0.0, 0.0, 0.0));
character.move_to(Vec3::new(50.0, 0.0, 30.0), &pathfinder)?;
loop {
let delta_time = 0.016;
character.update(delta_time, &pathfinder);
println!("Position: {:?}", character.position());
std::thread::sleep(std::time::Duration::from_millis(16));
}
}
#![allow(unused)]
fn main() {
use astraweave_nav::debug::NavmeshDebugRenderer;
let mut debug_renderer = NavmeshDebugRenderer::new();
// Render navmesh
debug_renderer.draw_navmesh(&navmesh, Color::GREEN);
// Render path
if let Some(path) = &agent.current_path {
debug_renderer.draw_path(path, Color::YELLOW);
}
// Render agent
debug_renderer.draw_agent(&agent, Color::RED);
// Render off-mesh links
debug_renderer.draw_offmesh_links(&navmesh, Color::CYAN);
}
For complete API documentation, see:
This page was rewritten on 2026-05-15 to reflect the engineering reality surfaced by the
architecture trace campaign. A prior version (added in commit 28bc94f21,
2025-09-08, by an automated documentation pass) described `InputSystem`, `InputConfig`,
`ActionMap`, `BindingRecorder`, `BindingProfile`, `ContextPriority`, `InputBuffer`,
`InputPredictor`, `InputRecorder` and a `mapping` / `rebinding` / `replay` / `buffer` /
`device` submodule layout. **None of those types or submodules exist** in
`astraweave-input/src/lib.rs` re-exports.
astraweave-input is a pure facade over winit (window/keyboard/mouse events) and
gilrs (gamepad). The lib.rs re-exports are:
Action — high-level input action enum.
Binding — single key/button/axis-to-action mapping.
BindingSet — collection of bindings (load / save / merge).
InputManager — runtime state, polls device events, drains an action queue.
Axis2 — 2D axis value (gamepad sticks, mouse delta).
That is the complete external surface. There is no InputSystem, no InputConfig,
no BindingRecorder, no InputBuffer, no InputPredictor, no InputRecorder.
Two workspace crates declare astraweave-input as a Cargo dependency but never
import it in any source file:
astraweave-gameplay/Cargo.toml
astraweave-ui/Cargo.toml
Verified by workspace-grep for use astraweave_input. The dep additions left no
source-file imports in git history. This is documented in input.md §4 and §11
and tracked as Q21 in ARCHITECTURE_MAP.md §14.
The single in-tree consumer of astraweave-input is examples/ui_controls_demo,
and even that demo does not read the InputManager state — it matches raw
winit KeyCode directly.
The visual editor’s input-bindings panel
(tools/aw_editor/src/panels/input_bindings_panel.rs, 2,511 LoC, 13 types)
reimplements the entire input vocabulary in-place, without depending on
astraweave-input . This is one of the four confirmed instances of the §7.7
wrapped-component resource identity trap surfaced by the Editor Multi-Tool
Architecture campaign — the editor’s input layer and the engine’s input layer
manage the same logical resource but neither delegates to the other.
Tracked as Q22 in ARCHITECTURE_MAP.md §14.
load_bindings in astraweave-input/src/save.rs:16-19 collapses every error
mode (file not found, parse error, schema mismatch) to None. Users get a default
binding set with no diagnostic indication of what failed.
Need File
Public re-exports astraweave-input/src/lib.rs
Action & Binding types astraweave-input/src/
Editor’s parallel input domain tools/aw_editor/src/panels/input_bindings_panel.rs
In-tree consumer (raw KeyCode usage) examples/ui_controls_demo/src/main.rs
input.md — full input-system trace (file map, conflict map, decision log,
invariants, open questions).
ARCHITECTURE_MAP.md §2.3 (anomaly 8), §7.1, §14 (Q21, Q22).
Interactive workspace map — select astraweave-input to see the
declared-but-unused dependency edges from astraweave-gameplay and
astraweave-ui rendered as dashed/tee-terminated lines.
This page was rewritten on 2026-05-15 to reflect the engineering reality surfaced by the
architecture trace campaign. A prior version (added in commit 28bc94f21,
2025-09-08, by an automated documentation pass) described a QUIC-based transport and
API surface that **does not exist in the codebase**. If you arrived here from an
external link expecting that API, see the architecture-trace links below.
AstraWeave’s workspace contains two networking subsystems with disjoint data
models, wire formats, and integration patterns. Neither imports the other.
Data model: 2D grid, IVec2 positions. Maps to the original AI-companion / tactical
game model.
Wire format: JSON over WebSocket text frames via tokio-tungstenite. Not QUIC.
There is no quinn dependency in the workspace.
Tick: 60 Hz fixed tick on GameServer. Broadcast cadence: full snapshot every 60
ticks, deltas every 3 ticks.
Interest filtering: four Interest impls — Full, RadiusTeam, Fov, FovLos.
Trace: net.md — file map, conflict map, decision log, invariants, open questions.
Data model: ECS world, Vec3 positions. Matchmaking-oriented multiplayer.
Wire format: Codec::PostcardLz4 (standalone server) or Codec::Bincode (ECS Plugin
layer). Carried over WebSocket — wss:// for the standalone server, ws:// for the
ECS Plugin variant.
Matchmaking: room cap 4, tick_hz = 30 (hardcoded), 32-byte session key,
8-byte session hint.
Status (per trace §1): the standalone server/client trio is production-style; the
ECS Plugin layer is dormant — astraweave-stress-test declares the crate as a
dependency but never imports it.
Trace: net_ecs.md .
HMAC signature mismatch. The standalone server runs HMAC-SHA256 input-frame
verification; the standalone client still computes the legacy 16-byte XOR
sign16. Every signature verification fails for two independent reasons (length
mismatch + algorithm mismatch). The failure is warn! only — the server does not
kick the client. Tracked as Q17 in ARCHITECTURE_MAP.md §14.
apply_delta silent no-op on tick mismatch (astraweave-net/src/lib.rs:404-406).
EntityState type collision. Both crates define a struct named EntityState
with different field shapes ({ pos: IVec2, hp, team, ammo } vs.
{ position: Vec3, health }). Qualify imports.
Long-term disposition of the two subsystems is an open question (Q16 in §14
of the architecture map): coexist long-term, retire one, or refactor toward a
unified model?
Need File
Snapshot pipeline astraweave-net/src/lib.rs
Standalone server entry net/aw-net-server/src/main.rs
Standalone client entry net/aw-net-client/src/main.rs
ECS Plugin layer astraweave-net-ecs/src/lib.rs
Wire-format types net/aw-net-proto/src/lib.rs
Per `ARCHITECTURE_MAP.md` §5.1, `astraweave-fluids` is **dormant for runtime engine use**:
* **~84.5K LoC** across 35 source files plus 8 WGSL shaders — the single largest
dormant-code reservoir in the workspace.
* The **only** workspace consumer outside the crate itself is
`examples/fluids_demo/src/main.rs:18-21`.
* **No production game-loop crate** depends on `astraweave-fluids`. Specifically,
`astraweave-render`, `astraweave-gameplay`, `astraweave-physics`, `astraweave-scene`,
`astraweave-terrain`, and `astraweave-ecs` all lack the dependency.
* `astraweave-fluids/src/editor.rs` (5,823 LoC) is forward-design infrastructure —
the visual editor (`tools/aw_editor`) does not depend on `astraweave-fluids`,
verified 2026-05-12.
The crate exists, builds, and ships with comprehensive tests (2,404 tests / 600+ inline
plus an integration suite — benchmark-caliber by per-trace §10 grade). It is the
clearest example of the workspace's *in-design-but-tested* dormant-code category.
astraweave-fluids carries five parallel solver and manager surfaces that have not
been unified:
Surface Role
FluidSystemlib.rs PBD GPU pipeline
UnifiedSolverHigh-level coordinator
ResearchFluidSystemResearch-grade umbrella
PCISPHSystemStandalone PCISPH solver
WaterEffectsManagerVisual coordinator
Plus auxiliary types: WaterBuildingManager, CausticsSystem, WaterQualityPreset
(Low/Medium/High/Ultra/Custom).
The largest single file is simd_ops.rs at 39,554 LoC; second-largest is
editor.rs at 5,823 LoC.
Per docs/current/FLUIDS_RESEARCH_GRADE_ENHANCEMENT_PLAN.md (2026-05-12), the audit
rates current state at “Grade B (Good for games, insufficient for research)” — the
intent is research-grade simulation; the existing surface has comprehensive tests but
no production-engine integration path.
The fluids crate is the canonical example used by the architecture trace campaign to
illustrate the “wired beats tested” axiom:
A subsystem with passing tests and zero production callers is dormant code, not a
feature. Tests are necessary but not sufficient. The Integration Completeness
checklist requires a production caller, all registration surfaces touched, every
UI/API-exposed config field read, and the architecture trace current.
— ARCHITECTURE_MAP.md §4.4
Listing fluid simulation as a working AstraWeave engine feature would violate this
axiom. It is documented honestly here as a research surface awaiting a production
wiring decision (Q12 in ARCHITECTURE_MAP.md §14).
The fluids_demo example exercises the crate in isolation:
cargo run -p fluids_demo --release
This is the entire production exposure of the subsystem.
fluids.md — full fluids trace.
ARCHITECTURE_MAP.md §5.1 (dormant in-design inventory),
§4.4 (wired-beats-tested axiom), §14 (Q12 open question).
Interactive workspace map — the Dormant Surface Inventory story preset
highlights fluids along with the other ~200K LoC of dormant-but-designed surface.
Status : Production ReadyCoverage : 71.5%Crate : astraweave-terrain
AstraWeave’s terrain system provides procedural terrain generation using noise functions, voxel data, and biome classification with full LOD support and async streaming.
Feature Description
Voxel Data Density-based terrain with material IDs
Marching Cubes 256-configuration mesh generation
Biome System Climate-based biome distribution
Erosion Simulation Hydraulic, thermal, and wind erosion
LOD Management Distance-based quality with hysteresis
Async Streaming Background chunk loading
Benchmark Result
World chunk generation 15.06 ms
Marching cubes mesh ~5 ms per chunk
SIMD heightmap 2-3× faster than scalar
#![allow(unused)]
fn main() {
use astraweave_terrain::{
ChunkManager, TerrainChunk, ChunkId,
Heightmap, HeightmapConfig,
Biome, BiomeType,
};
// Create heightmap generator
let heightmap = Heightmap::new(HeightmapConfig {
seed: 12345,
octaves: 6,
frequency: 0.01,
amplitude: 100.0,
});
// Generate chunk
let chunk_id = ChunkId::new(0, 0);
let chunk = TerrainChunk::generate(&heightmap, chunk_id);
// Get height at world position
let height = heightmap.sample(world_x, world_z);
}
astraweave-terrain/
├── Generation
│ ├── noise_gen.rs # Noise generation
│ ├── noise_simd.rs # SIMD-optimized noise
│ ├── heightmap.rs # Heightmap generation
│ └── structures.rs # Structure placement
├── Voxel System
│ ├── voxel_data.rs # Voxel grid & chunks
│ ├── chunk.rs # Chunk management
│ └── terrain_modifier.rs # Runtime modification
├── Meshing
│ ├── meshing.rs # Mesh generation
│ ├── marching_cubes_tables.rs # MC lookup tables
│ └── lod_blending.rs # LOD mesh morphing
├── Biomes
│ ├── biome.rs # Biome types
│ ├── biome_blending.rs # Smooth transitions
│ ├── climate.rs # Climate simulation
│ └── scatter.rs # Vegetation placement
├── Erosion
│ ├── erosion.rs # Basic erosion
│ └── advanced_erosion.rs # Multi-type erosion
├── Streaming
│ ├── background_loader.rs # Async loading
│ ├── lod_manager.rs # LOD control
│ └── streaming_diagnostics.rs # Debug info
└── Integration
├── partition_integration.rs # World streaming
├── texture_splatting.rs # Material blending
└── terrain_persistence.rs # Save/load
#![allow(unused)]
fn main() {
use astraweave_terrain::{Voxel, Density, MaterialId, VoxelChunk, CHUNK_SIZE};
// Voxel with density and material
let voxel = Voxel {
density: Density::new(0.7), // 0.0 = air, 1.0 = solid
material: MaterialId::STONE,
};
// Create empty chunk
let mut chunk = VoxelChunk::new();
// Set voxel at local position
chunk.set(x, y, z, voxel);
// Get voxel
let v = chunk.get(x, y, z);
}
#![allow(unused)]
fn main() {
use astraweave_terrain::{ChunkCoord, ChunkId, CHUNK_SIZE};
// World position to chunk
let chunk_coord = ChunkCoord::from_world_pos(world_pos);
// Chunk ID for manager
let chunk_id = ChunkId::new(chunk_coord.x, chunk_coord.z);
// Local position within chunk
let local_pos = chunk_coord.to_local(world_pos);
}
#![allow(unused)]
fn main() {
use astraweave_terrain::{Heightmap, HeightmapConfig};
let config = HeightmapConfig {
seed: 42,
octaves: 6, // Detail levels
frequency: 0.01, // Base frequency
amplitude: 100.0, // Height range
persistence: 0.5, // Octave decay
lacunarity: 2.0, // Octave frequency multiplier
};
let heightmap = Heightmap::new(config);
// Sample height at position
let height = heightmap.sample(x, z);
// Get normal at position
let normal = heightmap.normal(x, z);
}
For bulk generation (2-3× faster):
#![allow(unused)]
fn main() {
use astraweave_terrain::SimdHeightmapGenerator;
let generator = SimdHeightmapGenerator::new(config);
// Generate entire chunk heightmap
let heights: Vec<f32> = generator.generate_chunk(chunk_coord);
}
#![allow(unused)]
fn main() {
use astraweave_terrain::{BiomeType, Biome, BiomeConfig};
pub enum BiomeType {
Ocean,
Beach,
Plains,
Forest,
Taiga,
Desert,
Savanna,
Jungle,
Swamp,
Mountain,
SnowyMountain,
Tundra,
}
// Biome configuration
let forest = BiomeConfig {
biome_type: BiomeType::Forest,
temperature_range: (0.3, 0.7),
humidity_range: (0.5, 0.8),
height_modifier: 1.0,
vegetation_density: 0.8,
};
}
#![allow(unused)]
fn main() {
use astraweave_terrain::{ClimateMap, ClimateConfig};
let climate = ClimateMap::new(ClimateConfig {
seed: 12345,
temperature_scale: 0.001,
humidity_scale: 0.002,
});
// Get biome at position
let biome = climate.get_biome_at(x, z);
// Get climate values
let (temp, humidity) = climate.sample(x, z);
}
#![allow(unused)]
fn main() {
use astraweave_terrain::{BiomeBlender, BiomeBlendConfig};
let blender = BiomeBlender::new(BiomeBlendConfig {
blend_distance: 16.0, // Transition width
noise_strength: 0.2, // Edge noise
});
// Get blended biome weights at position
let weights: Vec<BiomeWeight> = blender.get_weights(x, z);
// Apply to terrain materials
for weight in weights {
material.blend(weight.biome, weight.strength);
}
}
#![allow(unused)]
fn main() {
use astraweave_terrain::meshing::{ChunkMesh, MeshVertex};
// Generate mesh from voxel chunk
let mesh = ChunkMesh::from_voxels(&chunk, &heightmap);
// Access mesh data
for vertex in mesh.vertices() {
let pos = vertex.position;
let normal = vertex.normal;
let uv = vertex.uv;
}
}
For sharper edges:
#![allow(unused)]
fn main() {
use astraweave_terrain::meshing::DualContouring;
let dc = DualContouring::new();
let mesh = dc.generate(&chunk, edge_sharpness: 0.8);
}
#![allow(unused)]
fn main() {
use astraweave_terrain::meshing::{LodMeshGenerator, LodConfig};
let generator = LodMeshGenerator::new(LodConfig {
levels: vec![
LodLevel { distance: 0.0, subdivisions: 8 },
LodLevel { distance: 100.0, subdivisions: 4 },
LodLevel { distance: 300.0, subdivisions: 2 },
LodLevel { distance: 500.0, subdivisions: 1 },
],
});
// Generate LOD mesh for distance
let mesh = generator.generate(&chunk, camera_distance);
}
#![allow(unused)]
fn main() {
use astraweave_terrain::{
AdvancedErosionSimulator,
HydraulicErosionConfig,
ErosionPreset,
};
let erosion = AdvancedErosionSimulator::new();
// Apply preset
let config = ErosionPreset::MountainRivers.into_config();
erosion.simulate_hydraulic(&mut heightmap, config, iterations: 50000);
// Or custom config
let custom = HydraulicErosionConfig {
rain_amount: 0.01,
erosion_rate: 0.3,
deposition_rate: 0.3,
evaporation_rate: 0.02,
min_slope: 0.01,
};
}
#![allow(unused)]
fn main() {
use astraweave_terrain::ThermalErosionConfig;
let thermal_config = ThermalErosionConfig {
talus_angle: 0.5, // Max stable slope
erosion_rate: 0.1,
};
erosion.simulate_thermal(&mut heightmap, thermal_config, iterations: 1000);
}
#![allow(unused)]
fn main() {
use astraweave_terrain::WindErosionConfig;
let wind_config = WindErosionConfig {
wind_direction: Vec3::new(1.0, 0.0, 0.5).normalize(),
wind_strength: 0.5,
particle_count: 10000,
};
erosion.simulate_wind(&mut heightmap, wind_config, iterations: 5000);
}
#![allow(unused)]
fn main() {
use astraweave_terrain::{
BackgroundChunkLoader,
StreamingConfig,
StreamingStats,
};
let loader = BackgroundChunkLoader::new(StreamingConfig {
load_radius: 8, // Chunks to keep loaded
unload_radius: 12, // Distance to unload
max_concurrent_loads: 4, // Parallel chunk loading
priority_distance: 2, // High-priority radius
});
// Update from camera position
loader.update(camera_pos);
// Poll for completed chunks
while let Some(chunk) = loader.poll_completed() {
chunk_manager.insert(chunk);
}
// Get statistics
let stats: StreamingStats = loader.stats();
println!("Chunks loaded: {}", stats.chunks_loaded);
println!("Load queue: {}", stats.queue_length);
}
#![allow(unused)]
fn main() {
use astraweave_terrain::{LodManager, LodConfig, LodStats};
let lod_manager = LodManager::new(LodConfig {
hysteresis: 0.1, // Prevent LOD flickering
update_interval: 0.5, // Seconds between LOD updates
});
// Update LOD states
lod_manager.update(camera_pos, delta_time);
// Get current LOD for chunk
let lod_level = lod_manager.get_lod(chunk_id);
}
#![allow(unused)]
fn main() {
use astraweave_terrain::StreamingDiagnostics;
let diagnostics = StreamingDiagnostics::new();
// Record frame data
diagnostics.record_frame(frame_time, chunks_loaded, chunks_visible);
// Detect hitches
if let Some(hitch) = diagnostics.detect_hitch() {
warn!("Streaming hitch: {:?}", hitch);
}
// Generate report
let report = diagnostics.generate_report();
}
#![allow(unused)]
fn main() {
use astraweave_terrain::{TerrainModifier, VoxelOp, VoxelOpType};
let mut modifier = TerrainModifier::new(&mut chunk_manager);
// Dig sphere
modifier.apply(VoxelOp {
op_type: VoxelOpType::Subtract,
center: world_pos,
radius: 5.0,
material: None,
});
// Add material
modifier.apply(VoxelOp {
op_type: VoxelOpType::Add,
center: world_pos,
radius: 3.0,
material: Some(MaterialId::STONE),
});
// Smooth area
modifier.apply(VoxelOp {
op_type: VoxelOpType::Smooth,
center: world_pos,
radius: 4.0,
material: None,
});
// Batch operations for efficiency
modifier.begin_batch();
for op in operations {
modifier.apply(op);
}
modifier.end_batch(); // Regenerates affected meshes once
}
#![allow(unused)]
fn main() {
use astraweave_terrain::{
SplatMapGenerator,
SplatConfig,
SplatRule,
TerrainMaterial,
};
let generator = SplatMapGenerator::new(SplatConfig {
resolution: 512,
rules: vec![
SplatRule::height_based(TerrainMaterial::Grass, 0.0, 50.0),
SplatRule::height_based(TerrainMaterial::Rock, 50.0, 100.0),
SplatRule::height_based(TerrainMaterial::Snow, 100.0, 200.0),
SplatRule::slope_based(TerrainMaterial::Cliff, 0.7, 1.0),
],
});
let splat_map = generator.generate(&heightmap);
}
#![allow(unused)]
fn main() {
use astraweave_terrain::TriplanarWeights;
// Calculate triplanar weights from normal
let weights = TriplanarWeights::from_normal(vertex_normal);
// Sample texture with triplanar projection
let color =
sample_xz(uv_xz) * weights.y +
sample_xy(uv_xy) * weights.z +
sample_zy(uv_zy) * weights.x;
}
#![allow(unused)]
fn main() {
use astraweave_terrain::terrain_persistence::{save_terrain, load_terrain};
// Save modified terrain
save_terrain(&chunk_manager, "saves/terrain.bin")?;
// Load terrain
let loaded = load_terrain("saves/terrain.bin")?;
chunk_manager.restore(&loaded);
}
For AI-driven terrain queries:
#![allow(unused)]
fn main() {
use astraweave_terrain::{TerrainSolver, ResolvedLocation};
let solver = TerrainSolver::new(&chunk_manager);
// Find valid location for AI agent
let result: ResolvedLocation = solver.find_valid_location(
search_center,
radius: 10.0,
constraints: &[
Constraint::MinHeight(5.0),
Constraint::MaxSlope(0.3),
Constraint::RequireBiome(BiomeType::Forest),
],
)?;
println!("Found location: {:?}", result.position);
}
The Blueprint Zone system enables polygon-based spatial control over vegetation generation and heightmap injection, bridging .blend scene imports with the terrain scatter pipeline.
Editor Canvas → BlueprintZone → ZoneScatterGenerator → ZoneGenerationResult
↓ ↓ ↓ ↓
Polygon drawing ZoneRegistry Replica/Inspired placements + patches
(save/load) mode dispatch ↓
apply_heightmap_patches()
↓
TerrainChunk updates
#![allow(unused)]
fn main() {
use astraweave_terrain::blueprint_zone::*;
// Define a zone with polygon vertices
let zone = BlueprintZone {
id: ZoneId(1),
name: "Forest Clearing".into(),
vertices: vec![[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]],
source: ZoneSource::BlendScene {
pack_path: "assets/pine_forest.biomepack".into(),
placement_mode: PlacementMode::Replica,
},
priority: 0,
enabled: true,
};
// ZoneRegistry — CRUD, spatial queries, persistence
let mut registry = ZoneRegistry::new();
registry.add_zone(zone);
let zones = registry.zones_containing_point(50.0, 50.0);
registry.save(&Path::new("zones.json"))?;
let loaded = ZoneRegistry::load(&Path::new("zones.json"))?;
}
Mode Behavior
Replica 1:1 reproduction — fixed positions from .blend scene, scaled by AdaptiveScaleParams
Inspired Procedural scatter using ScatterConfig derived from BiomePack, respects zone polygon
BiomePreset Pure biome-driven scatter (Grassland, Forest, Desert, etc.) without .blend data
#![allow(unused)]
fn main() {
use astraweave_terrain::zone_scatter::*;
let gen = ZoneScatterGenerator::new(256.0, 128); // chunk_size, heightmap_resolution
let result = gen.generate_zone_scatter(&zone, &biome_pack)?;
// result.placements — Vec<VegetationInstance> (position, rotation, scale, model_path)
// result.heightmap_patches — Vec<HeightmapPatch> (per-chunk height modifications)
// Apply heightmap patches to terrain chunks
let results = vec![result];
apply_heightmap_patches(&mut chunk_map, &results);
}
When zone area differs from source scene footprint, density and scale adjust automatically:
#![allow(unused)]
fn main() {
let params = AdaptiveScaleParams::compute(reference_area, zone_area);
// density_multiplier = sqrt(zone_area / reference_area)
// scale_multiplier = (zone_area / reference_area)^0.25
// position_scale = sqrt(zone_area / reference_area)
}
apply_boundary_blending() uses smoothstep falloff at zone edges:
Vegetation : density fades via BlendMask::sample(x, z) → 0.0–1.0
Heightmap : height delta scaled by mask value at each sample point
Manual : BrushMode::ZoneBlend for blend-weight painting in-editor
Terrain meshes from .blend decomposition are rasterized into heightmaps:
#![allow(unused)]
fn main() {
use astraweave_blend::heightmap_raster::*;
let heightmap = rasterize_terrain_meshes(&terrain_meshes, resolution)?;
let height = heightmap.sample_bilinear(u, v); // Normalized [0,1] coords
let area = heightmap.footprint_area(); // World-space area in m²
}
The BlueprintPanel provides a 2D canvas for polygon zone editing:
Tools: Select, DrawPolygon, MoveVertex, DeleteZone
Zone inspector with name, source selection, placement mode toggle
Undo/redo via command stack
Save/Load zones as .zones.json
The BlueprintOverlay projects zone polygons as wireframe outlines into the 3D viewport, integrated with the physics renderer debug line pass.
Test Suite Tests
blueprint_zone unit tests24
zone_scatter unit + integration27
heightmap_raster unit + integration21
biome_pack extended9
blueprint_panel unit tests17
blueprint_overlay unit tests7
blend_scanner unit tests8
Total 113+
Status : Production ReadyTests : 125+Crates : astraweave-terrain, astraweave-blend, aw_editor
The Blueprint Zone system provides polygon-based spatial control for vegetation generation and heightmap injection. It bridges .blend scene imports with AstraWeave’s terrain scatter pipeline, enabling both exact 1:1 reproduction (Replica mode) and procedural variation (Inspired mode) of imported environments.
.blend Scene → Decomposition → BiomePack → BlueprintZone → ZoneScatterGenerator
↓ ↓ ↓
heightmap_raster ZoneRegistry VegetationInstance
(terrain mesh → (polygon CRUD, + HeightmapPatch
heightmap + fixed spatial queries, ↓
placements) JSON persistence) apply_heightmap_patches()
↓
TerrainChunk updates
Feature Description
Polygon Zones Arbitrary polygons define generation boundaries
Replica Mode 1:1 fixed placement from .blend scene data
Inspired Mode Procedural scatter using extracted ScatterConfig
Biome Presets Pure biome-driven scatter without .blend data
Heightmap Injection Rasterized terrain meshes applied as height patches
Boundary Blending Smoothstep falloff prevents hard cutoffs at zone edges
Adaptive Scaling Auto-adjusts density/scale when zone ≠ source area
Undo/Redo Full command stack in blueprint panel
3D Overlay Wireframe zone projection in viewport
#![allow(unused)]
fn main() {
use astraweave_terrain::blueprint_zone::*;
use astraweave_terrain::zone_scatter::*;
use std::path::Path;
// 1. Define a zone
let zone = BlueprintZone {
id: ZoneId(1),
name: "Pine Forest".into(),
vertices: vec![
[0.0, 0.0], [200.0, 0.0],
[200.0, 150.0], [0.0, 150.0],
],
source: ZoneSource::BlendScene {
pack_path: "assets/pine_forest.biomepack".into(),
placement_mode: PlacementMode::Replica,
},
priority: 0,
enabled: true,
};
// 2. Register it
let mut registry = ZoneRegistry::new();
registry.add_zone(zone.clone());
// 3. Generate scatter
let gen = ZoneScatterGenerator::new(256.0, 128);
let result = gen.generate_zone_scatter(&zone, &biome_pack)?;
println!("{} placements", result.placement_count());
println!("{} height modifications", result.modified_height_count());
// 4. Apply heightmap patches to terrain
let mut chunks = HashMap::new();
// ... populate with TerrainChunks ...
apply_heightmap_patches(&mut chunks, &[result]);
// 5. Persist zones
registry.save(Path::new("assets/zones.json"))?;
}
A BlueprintZone represents a polygon region with a vegetation/terrain source:
#![allow(unused)]
fn main() {
pub struct BlueprintZone {
pub id: ZoneId, // Unique identifier
pub name: String, // Display name
pub vertices: Vec<[f32; 2]>, // Polygon vertices (XZ plane)
pub source: ZoneSource, // What to generate
pub priority: u32, // Overlap resolution (higher wins)
pub enabled: bool, // Toggle generation
}
}
#![allow(unused)]
fn main() {
pub enum ZoneSource {
BiomePreset(BiomeType), // Built-in biome scatter
BlendScene {
pack_path: String, // Path to .biomepack
placement_mode: PlacementMode, // Replica or Inspired
},
}
}
Mode Description
ReplicaExact positions from .blend scene, scaled by AdaptiveScaleParams
InspiredProcedural scatter using extracted density/distribution rules
#![allow(unused)]
fn main() {
let mut registry = ZoneRegistry::new();
// CRUD operations
registry.add_zone(zone);
registry.remove_zone(zone_id);
let zone = registry.get_zone(zone_id);
let zone_mut = registry.get_zone_mut(zone_id);
// Spatial queries
let zones = registry.zones_containing_point(x, z);
let overlaps = registry.zones_overlapping_rect(min_x, min_z, max_x, max_z);
// Persistence
registry.save(Path::new("zones.json"))?;
let loaded = ZoneRegistry::load(Path::new("zones.json"))?;
}
#![allow(unused)]
fn main() {
use astraweave_terrain::zone_scatter::*;
// Create generator with chunk size and heightmap resolution
let gen = ZoneScatterGenerator::new(256.0, 128);
// Generate for a single zone
let result: ZoneGenerationResult = gen.generate_zone_scatter(&zone, &biome_pack)?;
}
#![allow(unused)]
fn main() {
pub struct ZoneGenerationResult {
pub placements: Vec<VegetationInstance>, // What to place
pub heightmap_patches: Vec<HeightmapPatch>, // Height modifications
}
// VegetationInstance fields:
// - position: Vec3
// - rotation: f32
// - scale: f32
// - vegetation_type: String
// - model_path: String
// - terrain_normal: Vec3
}
#![allow(unused)]
fn main() {
// Generate scatter for all zones with overlap priority resolution
let results = generate_multi_zone_scatter(&zones, &gen, &biome_packs)?;
// Apply all heightmap patches at once
apply_heightmap_patches(&mut chunk_map, &results);
}
When a zone’s area differs from the source scene’s footprint, scaling parameters adjust automatically:
#![allow(unused)]
fn main() {
let params = AdaptiveScaleParams::compute(reference_area, zone_area);
}
Parameter Formula Effect
density_multiplier$\sqrt{\frac{\text{zone}}{\text{ref}}}$ Adjusts placement density
scale_multiplier$\left(\frac{\text{zone}}{\text{ref}}\right)^{0.25}$ Adjusts object scale
position_scale$\sqrt{\frac{\text{zone}}{\text{ref}}}$ Adjusts position spread
Example : A zone 4× larger than the source scene would have:
density_multiplier = 2.0 (double the objects)
scale_multiplier ≈ 1.41 (slightly larger objects)
position_scale = 2.0 (spread positions wider)
Zone edges use smoothstep interpolation to prevent hard vegetation/height cutoffs:
#![allow(unused)]
fn main() {
// BlendMask provides per-point blending weights
let mask = BlendMask::new(resolution, world_bounds);
let weight = mask.sample(x, z); // 0.0 at edge → 1.0 at center
}
The editor provides BrushMode::ZoneBlend for manually painting blend weights at zone boundaries, giving artists fine control over transition regions.
Terrain meshes extracted from .blend files are rasterized into heightmaps:
#![allow(unused)]
fn main() {
use astraweave_blend::heightmap_raster::*;
let heightmap = rasterize_terrain_meshes(&terrain_meshes, 128)?;
// Query rasterized data
let height = heightmap.sample_bilinear(0.5, 0.5); // Normalized UV coords
let area = heightmap.footprint_area(); // m² world-space
// Fixed placements (exact object positions from scene)
let placements: Vec<FixedPlacement> = extract_fixed_placements(&scene_objects);
}
The rasterizer uses ray-triangle intersection with:
Seam averaging for multi-tile terrain boundaries
Hole filling via neighbor interpolation for missing samples
The BlueprintPanel (panel type: Blueprint) provides:
2D Canvas : Pan/zoom view for polygon zone drawing
Tools : Select, DrawPolygon, MoveVertex, DeleteZone
Zone Inspector : Name, source selection (biome preset or blend scene), placement mode toggle
Undo/Redo : Full BlueprintCommand stack with Ctrl+Z/Ctrl+Shift+Z
Persistence : Save/Load zones as .zones.json files
BlueprintOverlay projects zone polygons as colored wireframe outlines in the 3D viewport. Zone boundaries are rendered alongside component gizmos and brush cursors in the physics renderer debug line pass.
The BlendAssetScanner adds .blend file discovery to the asset browser with:
Automatic directory scanning for .blend files
Decomposition status detection (checking for manifest.json)
Quick actions: Import Blend Scene, Use as Zone Source
The editor’s update loop dispatches BlueprintAction events:
Action Handler Effect
GenerateZonehandle_generate_zone()Runs ZoneScatterGenerator for one zone
GenerateAllIterates all zones Generates scatter for every enabled zone
ClearGenerationClears results Removes generated placements
SaveZoneshandle_save_zones()Saves ZoneRegistry to JSON
LoadZoneshandle_load_zones()Loads registry + syncs panel state
After each action, sync_zone_overlay() pushes updated zone data to the viewport.
Suite Count Scope
blueprint_zone unit24 Zone model, registry, polygon math, persistence
zone_scatter unit16 Generation modes, blending, heightmap patches
zone_scatter_e2e integration11 Full pipeline, multi-zone, chunk spanning
heightmap_raster unit11 Rasterization, terrain bounds, seam stitching
heightmap_raster_e2e integration10 Multi-tile, bilinear sampling, edge cases
biome_pack unit9 BiomePack extension fields, detection
blueprint_panel unit17 Canvas, tools, undo/redo, actions
blueprint_overlay unit7 Wireframe generation, color mapping
blend_scanner unit8 Directory scanning, status detection
Total 113+
⚠️ This chapter is outdated and pending a full rewrite (Unified Camera campaign C.7.F / post-campaign cleanup).
This chapter predates the C.7 cinematics consolidation and documents an
outdated camera model. Specifically, it describes CameraKey with a
rotation-based orientation (rot / Euler angles) and field/syntax that
do not match the current astraweave-cinematics API. The canonical
CameraKey uses a look-at target model:
CameraKey { t: Time, pos: (f32, f32, f32), look_at: (f32, f32, f32), fov_deg: f32 }.
Several APIs referenced below (track-construction helpers, camera setter
methods) may also not exist in the current crate.
For accurate, current documentation of the cinematics camera, see:
A full rewrite of this chapter — re-authoring its worked examples against
the canonical look-at model and verifying every documented API symbol
against the actual crate surface — is tracked as C.7.F in the
post-campaign cleanup queue. Until then, treat this chapter’s specifics
as unreliable.
Status : Production ReadyCrate : astraweave-cinematicsCoverage : ~85%
The cinematics system provides a timeline-based sequencer for creating cutscenes, scripted events, and cinematic sequences with synchronized camera, animation, audio, and visual effects tracks.
Type Description
TimePrecise time representation (seconds/milliseconds)
TrackTrack variant (Camera, Animation, Audio, Fx)
CameraKeyCamera keyframe with position, rotation, FOV
TimelineCollection of tracks with duration
SequencerPlayback engine with seek and step
SequencerEventEvents emitted during playback
#![allow(unused)]
fn main() {
use astraweave_cinematics::{Time, Timeline, Track, CameraKey, Sequencer};
// Create a 10-second timeline
let mut timeline = Timeline::new("intro_cutscene", 10.0);
// Add camera keyframes
let keyframes = vec![
CameraKey::new(Time::zero(), [0.0, 5.0, -10.0], [0.0, 0.0, 0.0], 60.0),
CameraKey::new(Time::from_secs(5.0), [10.0, 8.0, -5.0], [0.0, 45.0, 0.0], 55.0),
CameraKey::new(Time::from_secs(10.0), [0.0, 3.0, 0.0], [0.0, 90.0, 0.0], 70.0),
];
timeline.add_camera_track(keyframes);
// Add audio
timeline.add_audio_track("music/intro.ogg", Time::zero(), 0.8);
// Add animation trigger
timeline.add_track(Track::animation(1, "character_wave", Time::from_secs(2.0)));
// Add FX
timeline.add_track(Track::fx("explosion", Time::from_secs(8.0), serde_json::json!({
"scale": 2.0,
"particles": 500
})));
// Playback
let mut sequencer = Sequencer::new();
let dt = 1.0 / 60.0; // 60 FPS
loop {
match sequencer.step(dt, &timeline) {
Ok(events) => {
for event in events {
handle_event(event);
}
}
Err(_) => break, // End of timeline
}
}
}
Timeline "intro_cutscene" (10.0s)
├── Track::Camera
│ ├── Keyframe @ 0.0s (pos, rot, fov)
│ ├── Keyframe @ 5.0s
│ └── Keyframe @ 10.0s
├── Track::Audio "music/intro.ogg" @ 0.0s
├── Track::Animation target=1 "character_wave" @ 2.0s
└── Track::Fx "explosion" @ 8.0s
┌───────────────────────────────────────────────────┐
│ Sequencer │
│ ┌─────────┐ ┌───────────┐ ┌─────────────┐ │
│ │ seek() │ → │ step(dt) │ → │ events │ │
│ └─────────┘ └───────────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ For each track, emit events where │ │
│ │ start_time > previous_t && start_time <= t │ │
│ └─────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
The Time type provides precision time handling:
#![allow(unused)]
fn main() {
use astraweave_cinematics::Time;
// Construction
let t1 = Time::zero(); // 0.0s
let t2 = Time::from_secs(5.0); // 5.0s
let t3 = Time::from_millis(1500.0); // 1.5s
// Access
println!("Seconds: {}", t2.as_secs()); // 5.0
println!("Millis: {}", t2.as_millis()); // 5000.0
// Arithmetic
let sum = t2 + t3; // 6.5s
let diff = t2 - t3; // 3.5s
let added = t2.add_secs(2.5); // 7.5s
// Utilities
let clamped = t2.clamp(Time::zero(), Time::from_secs(4.0)); // 4.0s
let lerped = Time::zero().lerp(Time::from_secs(10.0), 0.3); // 3.0s
// Display
println!("{}", Time::from_secs(2.5)); // "2.50s"
println!("{}", Time::from_millis(500.0)); // "500ms"
}
Controls camera position, rotation, and field of view:
#![allow(unused)]
fn main() {
use astraweave_cinematics::{Track, CameraKey, Time};
let track = Track::camera(vec![
CameraKey {
t: Time::zero(),
pos: [0.0, 5.0, -10.0],
rot: [0.0, 0.0, 0.0], // Euler angles (degrees)
fov: 60.0, // Field of view
},
CameraKey {
t: Time::from_secs(5.0),
pos: [10.0, 8.0, -5.0],
rot: [0.0, 45.0, 0.0],
fov: 55.0,
},
]);
assert!(track.is_camera());
assert_eq!(track.keyframe_count(), Some(2));
}
Triggers character/object animations:
#![allow(unused)]
fn main() {
let track = Track::animation(
42, // Target entity ID
"run_forward", // Animation clip name
Time::from_secs(1.5), // Start time
);
assert!(track.is_animation());
assert_eq!(track.start_time(), Some(Time::from_secs(1.5)));
}
Plays sound effects or music:
#![allow(unused)]
fn main() {
let track = Track::audio(
"sfx/explosion.wav", // Audio clip path
Time::from_secs(3.0), // Start time
0.8, // Volume (0.0 - 1.0)
);
assert!(track.is_audio());
}
Triggers visual effects with parameters:
#![allow(unused)]
fn main() {
use serde_json::json;
let track = Track::fx(
"particle_burst", // Effect name
Time::from_secs(2.0), // Start time
json!({
"count": 100,
"color": [1.0, 0.5, 0.0],
"lifetime": 2.0
}),
);
assert!(track.is_fx());
}
#![allow(unused)]
fn main() {
use astraweave_cinematics::Timeline;
// Named timeline with duration
let mut timeline = Timeline::new("boss_intro", 30.0);
// Empty timeline
let empty = Timeline::empty();
assert!(empty.is_empty());
}
#![allow(unused)]
fn main() {
let mut timeline = Timeline::new("action_sequence", 15.0);
// Add tracks
timeline.add_camera_track(camera_keyframes);
timeline.add_audio_track("music/action.ogg", Time::zero(), 1.0);
timeline.add_track(Track::animation(1, "attack", Time::from_secs(5.0)));
// Track counts
println!("Camera tracks: {}", timeline.camera_track_count());
println!("Audio tracks: {}", timeline.audio_track_count());
println!("Animation tracks: {}", timeline.animation_track_count());
println!("FX tracks: {}", timeline.fx_track_count());
println!("Total tracks: {}", timeline.track_count());
println!("Total keyframes: {}", timeline.total_keyframes());
}
#![allow(unused)]
fn main() {
let timeline = Timeline::new("example", 10.0);
println!("Name: {}", timeline.name);
println!("Duration: {}s", timeline.duration_secs());
println!("Empty: {}", timeline.is_empty());
// Display
println!("{}", timeline); // Timeline("example", duration=10.00s, 0 tracks)
}
#![allow(unused)]
fn main() {
use astraweave_cinematics::Sequencer;
let mut seq = Sequencer::new();
let dt = 1.0 / 60.0; // 60 FPS
// Step through timeline
loop {
match seq.step(dt, &timeline) {
Ok(events) => {
for event in events {
process_event(event);
}
}
Err(SeqError::Range(_)) => {
println!("Timeline complete");
break;
}
}
}
}
#![allow(unused)]
fn main() {
let mut seq = Sequencer::new();
// Jump to specific time
seq.seek(Time::from_secs(5.0));
// Current time
println!("Current: {}", seq.t);
}
#![allow(unused)]
fn main() {
use astraweave_cinematics::SequencerEvent;
fn handle_event(event: SequencerEvent) {
match event {
SequencerEvent::CameraKey(key) => {
camera.set_position(key.pos);
camera.set_rotation(key.rot);
camera.set_fov(key.fov);
}
SequencerEvent::AnimStart { target, clip } => {
if let Some(entity) = world.get_entity(target) {
animator.play(entity, &clip);
}
}
SequencerEvent::AudioPlay { clip, volume } => {
audio.play(&clip, volume);
}
SequencerEvent::FxTrigger { name, params } => {
fx_system.trigger(&name, params);
}
}
}
}
Timelines are fully serializable with Serde:
#![allow(unused)]
fn main() {
use astraweave_cinematics::Timeline;
// Save to JSON
let json = serde_json::to_string_pretty(&timeline)?;
std::fs::write("cutscene.json", &json)?;
// Load from JSON
let loaded: Timeline = serde_json::from_str(&json)?;
// Save to RON (common in game dev)
let ron = ron::to_string(&timeline)?;
}
Example JSON :
{
"name": "intro",
"duration": 10.0,
"tracks": [
{
"Camera": {
"keyframes": [
{ "t": 0.0, "pos": [0, 5, -10], "rot": [0, 0, 0], "fov": 60 },
{ "t": 5.0, "pos": [10, 8, -5], "rot": [0, 45, 0], "fov": 55 }
]
}
},
{
"Audio": {
"clip": "music/intro.ogg",
"start": 0.0,
"volume": 0.8
}
}
]
}
#![allow(unused)]
fn main() {
use astraweave_ecs::World;
use astraweave_cinematics::{Sequencer, Timeline};
fn cinematics_system(world: &mut World, timeline: &Timeline, seq: &mut Sequencer, dt: f32) {
if let Ok(events) = seq.step(dt, timeline) {
for event in events {
match event {
SequencerEvent::CameraKey(key) => {
// Update camera component
if let Some(camera) = world.query_mut::<CameraComponent>().next() {
camera.position = key.pos.into();
camera.rotation = key.rot.into();
camera.fov = key.fov;
}
}
SequencerEvent::AnimStart { target, clip } => {
// Trigger animation on entity
if let Some(anim) = world.get_component_mut::<Animator>(target) {
anim.play(&clip);
}
}
// ... handle other events
}
}
}
}
}
#![allow(unused)]
fn main() {
enum GameState {
Playing,
Cutscene { timeline: Timeline, sequencer: Sequencer },
}
impl GameState {
fn update(&mut self, dt: f32) -> Option<SequencerEvent> {
match self {
GameState::Cutscene { timeline, sequencer } => {
match sequencer.step(dt, timeline) {
Ok(events) => {
for event in events {
// Handle events
}
None
}
Err(_) => {
// Cutscene complete, return to gameplay
*self = GameState::Playing;
None
}
}
}
_ => None,
}
}
}
}
Operation Latency Notes
Time creation~1 ns Zero-cost abstraction
step() per track~50 ns Linear scan of events
Event emission ~100 ns Allocation per event
Full timeline step ~500 ns Typical 10-track timeline
Pre-sort tracks by start time for faster event lookup
Reuse sequencers instead of creating new ones
Batch event handling when multiple events fire simultaneously
Use seek() sparingly - stepping is more efficient
#![allow(unused)]
fn main() {
let mut seq = Sequencer::new();
loop {
match seq.step(dt, &timeline) {
Ok(events) => handle_events(events),
Err(_) => {
// Loop back to start
seq.seek(Time::zero());
}
}
}
}
#![allow(unused)]
fn main() {
fn update_cutscene(seq: &mut Sequencer, timeline: &Timeline, dt: f32, skip_pressed: bool) {
if skip_pressed {
// Jump to end
seq.seek(timeline.duration);
return;
}
if let Ok(events) = seq.step(dt, timeline) {
handle_events(events);
}
}
}
#![allow(unused)]
fn main() {
struct BranchingCutscene {
timelines: HashMap<String, Timeline>,
current: String,
sequencer: Sequencer,
}
impl BranchingCutscene {
fn switch_to(&mut self, name: &str) {
self.current = name.to_string();
self.sequencer.seek(Time::zero());
}
fn update(&mut self, dt: f32) -> Vec<SequencerEvent> {
let timeline = &self.timelines[&self.current];
self.sequencer.step(dt, timeline).unwrap_or_default()
}
}
}
This guide walks you through building a complete game with AstraWeave, from project setup to a playable demo featuring AI companions, physics, and navigation.
Before starting, ensure you have:
- Rust 1.75+ installed
- AstraWeave cloned and building successfully
- Ollama running with a compatible model (see [Installation Guide](../getting-started/installation.md))
We’ll build “Companion Quest” - a small adventure where an AI companion helps the player navigate a dungeon, solve puzzles, and defeat enemies. This showcases:
AI companion with LLM-powered dialogue
Physics-based puzzles
Navigation and pathfinding
Combat with adaptive enemies
Save/load functionality
Create a new directory in examples/:
mkdir -p examples/companion_quest/src
Create examples/companion_quest/Cargo.toml:
[package]
name = "companion_quest"
version = "0.1.0"
edition = "2021"
[dependencies]
astraweave-core = { path = "../../astraweave-core" }
astraweave-ecs = { path = "../../astraweave-ecs" }
astraweave-ai = { path = "../../astraweave-ai" }
astraweave-physics = { path = "../../astraweave-physics" }
astraweave-nav = { path = "../../astraweave-nav" }
astraweave-render = { path = "../../astraweave-render" }
astraweave-audio = { path = "../../astraweave-audio" }
astraweave-input = { path = "../../astraweave-input" }
astraweave-llm = { path = "../../astraweave-llm" }
astraweave-gameplay = { path = "../../astraweave-gameplay" }
tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] }
Create examples/companion_quest/src/main.rs:
use astraweave_core::prelude::*;
use astraweave_ecs::prelude::*;
use astraweave_ai::prelude::*;
use astraweave_physics::prelude::*;
use astraweave_nav::prelude::*;
fn main() {
let mut world = World::new();
setup_world(&mut world);
setup_player(&mut world);
setup_companion(&mut world);
setup_dungeon(&mut world);
run_game_loop(&mut world);
}
fn setup_world(world: &mut World) {
world.insert_resource(PhysicsConfig::default());
world.insert_resource(NavigationConfig::default());
world.insert_resource(AiConfig {
tick_budget_ms: 8,
max_concurrent_plans: 4,
..Default::default()
});
}
The player entity needs transform, physics, and input components:
#![allow(unused)]
fn main() {
fn setup_player(world: &mut World) {
let player = world.spawn((
Transform::from_xyz(0.0, 1.0, 0.0),
RigidBody::Dynamic,
Collider::capsule(0.5, 1.8),
Player {
health: 100.0,
max_health: 100.0,
stamina: 100.0,
},
InputReceiver::default(),
CharacterController {
speed: 5.0,
jump_force: 8.0,
..Default::default()
},
));
world.insert_resource(PlayerEntity(player));
}
#[derive(Component)]
struct Player {
health: f32,
max_health: f32,
stamina: f32,
}
}
The companion uses AstraWeave’s AI-native architecture:
#![allow(unused)]
fn main() {
fn setup_companion(world: &mut World) {
let companion = world.spawn((
Transform::from_xyz(2.0, 1.0, 0.0),
RigidBody::Dynamic,
Collider::capsule(0.4, 1.6),
AiAgent {
personality: "helpful and curious".into(),
knowledge_base: vec![
"I am Spark, a magical companion".into(),
"I can help solve puzzles and fight enemies".into(),
],
},
PerceptionRadius(15.0),
NavAgent {
speed: 4.0,
acceleration: 10.0,
avoidance_radius: 0.5,
},
Companion {
following: true,
follow_distance: 3.0,
},
DialogueCapable::default(),
));
world.insert_resource(CompanionEntity(companion));
}
#[derive(Component)]
struct Companion {
following: bool,
follow_distance: f32,
}
}
Create a simple dungeon with rooms and corridors:
#![allow(unused)]
fn main() {
fn setup_dungeon(world: &mut World) {
let floor = world.spawn((
Transform::from_xyz(0.0, 0.0, 0.0),
RigidBody::Static,
Collider::cuboid(50.0, 0.5, 50.0),
Floor,
));
spawn_walls(world);
spawn_doors(world);
spawn_puzzles(world);
spawn_enemies(world);
generate_navmesh(world);
}
fn generate_navmesh(world: &mut World) {
let navmesh = NavMeshBuilder::new()
.cell_size(0.3)
.cell_height(0.2)
.agent_radius(0.5)
.agent_height(1.8)
.max_slope(45.0)
.build_from_world(world);
world.insert_resource(navmesh);
}
}
AstraWeave uses a fixed-tick simulation for determinism:
#![allow(unused)]
fn main() {
fn run_game_loop(world: &mut World) {
let mut scheduler = Scheduler::new();
scheduler.add_system(input_system);
scheduler.add_system(player_movement_system);
scheduler.add_system(ai_perception_system);
scheduler.add_system(ai_planning_system);
scheduler.add_system(companion_follow_system);
scheduler.add_system(navigation_system);
scheduler.add_system(physics_system);
scheduler.add_system(combat_system);
scheduler.add_system(dialogue_system);
scheduler.add_system(render_system);
let tick_rate = Duration::from_secs_f64(1.0 / 60.0);
let mut accumulator = Duration::ZERO;
let mut last_time = Instant::now();
loop {
let now = Instant::now();
let delta = now - last_time;
last_time = now;
accumulator += delta;
while accumulator >= tick_rate {
scheduler.run(world);
accumulator -= tick_rate;
}
if should_quit(world) {
break;
}
}
}
}
The companion uses perception and planning:
#![allow(unused)]
fn main() {
fn companion_follow_system(
player_query: Query<&Transform, With<Player>>,
mut companion_query: Query<(&mut NavAgent, &Companion, &Transform)>,
navmesh: Res<NavMesh>,
) {
let player_pos = player_query.single().translation;
for (mut nav_agent, companion, transform) in companion_query.iter_mut() {
if !companion.following {
continue;
}
let distance = transform.translation.distance(player_pos);
if distance > companion.follow_distance {
let target = player_pos - (player_pos - transform.translation)
.normalize() * companion.follow_distance;
if let Some(path) = navmesh.find_path(transform.translation, target) {
nav_agent.set_path(path);
}
}
}
}
}
Enable LLM-powered dialogue with the companion:
#![allow(unused)]
fn main() {
fn dialogue_system(
mut dialogue_events: EventReader<DialogueEvent>,
mut llm_client: ResMut<LlmClient>,
companion_query: Query<&AiAgent, With<Companion>>,
mut dialogue_responses: EventWriter<DialogueResponse>,
) {
for event in dialogue_events.read() {
let agent = companion_query.single();
let prompt = format!(
"You are {}. The player says: '{}'. Respond in character.",
agent.personality,
event.message
);
match llm_client.generate_blocking(&prompt) {
Ok(response) => {
dialogue_responses.send(DialogueResponse {
speaker: "Spark".into(),
text: response,
});
}
Err(e) => {
dialogue_responses.send(DialogueResponse {
speaker: "Spark".into(),
text: "Hmm, I'm not sure what to say...".into(),
});
}
}
}
}
}
Add combat with tool validation:
#![allow(unused)]
fn main() {
fn combat_system(
mut combat_events: EventReader<CombatEvent>,
mut health_query: Query<&mut Health>,
tool_validator: Res<ToolValidator>,
) {
for event in combat_events.read() {
let validation = tool_validator.validate(&ToolCall {
tool: "attack".into(),
params: json!({
"attacker": event.attacker,
"target": event.target,
"damage": event.damage,
}),
});
match validation {
ToolResult::Success => {
if let Ok(mut health) = health_query.get_mut(event.target) {
health.current -= event.damage;
}
}
ToolResult::Blocked(reason) => {
println!("Attack blocked: {}", reason);
}
}
}
}
}
Build and run:
cargo run -p companion_quest --release
[INFO] AstraWeave v0.1.0 starting...
[INFO] Physics initialized: Rapier 0.17
[INFO] Navigation: NavMesh built (2,450 polygons)
[INFO] AI: Connected to Ollama (hermes2-pro)
[INFO] Companion "Spark" spawned at (2.0, 1.0, 0.0)
[INFO] Game loop started at 60 Hz
See the full working example at examples/companion_quest/ or explore these related examples:
Example Description
hello_companionMinimal AI companion demo
adaptive_bossMulti-phase adaptive boss fight
quest_dialogue_demoDialogue and quest system
combat_physics_demoPhysics-based combat
unified_showcaseFull engine demonstration
Enable release mode (`--release`) for LLM inference. Debug builds can be 10-50x slower for AI operations.
AI companions are a signature feature of AstraWeave. Unlike scripted NPCs in traditional engines, companions in AstraWeave use LLM-powered planning to make intelligent decisions while respecting game rules through tool validation.
Companions cannot cheat - they perceive the world, plan actions, and execute through the same validated systems as players.
graph LR
subgraph Companion
Perception[Perception]
Memory[Memory]
Personality[Personality]
Planning[LLM Planning]
Actions[Action Queue]
end
World[World State] --> Perception
Perception --> Memory
Memory --> Planning
Personality --> Planning
Planning --> Actions
Actions --> World
#![allow(unused)]
fn main() {
use astraweave_ai::prelude::*;
use astraweave_ecs::prelude::*;
fn spawn_companion(world: &mut World, name: &str, personality: &str) -> Entity {
world.spawn((
Name::new(name),
Transform::from_xyz(0.0, 0.0, 0.0),
AiAgent::new()
.with_personality(personality)
.with_perception_radius(20.0)
.with_memory_capacity(100),
CompanionBehavior {
follow_distance: 3.0,
engage_distance: 10.0,
flee_health_threshold: 0.2,
},
AvailableTools::new(vec![
Tool::move_to(),
Tool::follow(),
Tool::attack(),
Tool::defend(),
Tool::use_item(),
Tool::speak(),
Tool::investigate(),
]),
PerceptionState::default(),
NavAgent::default(),
DialogueCapable::default(),
Health::new(100.0),
))
}
}
Companions have distinct personalities that influence their decisions:
#![allow(unused)]
fn main() {
let warrior_companion = AiAgent::new()
.with_personality("brave and protective warrior who prioritizes the player's safety")
.with_traits(vec![
Trait::Brave,
Trait::Protective,
Trait::DirectCommunicator,
])
.with_knowledge(vec![
"I am a seasoned warrior".into(),
"I protect my allies at all costs".into(),
"I prefer melee combat".into(),
]);
let scholar_companion = AiAgent::new()
.with_personality("curious scholar who loves discovering secrets and solving puzzles")
.with_traits(vec![
Trait::Curious,
Trait::Analytical,
Trait::Cautious,
])
.with_knowledge(vec![
"I study ancient texts and artifacts".into(),
"I prefer avoiding combat when possible".into(),
"I excel at puzzle-solving".into(),
]);
}
#![allow(unused)]
fn main() {
fn companion_follow_system(
player: Query<&Transform, With<Player>>,
mut companions: Query<(&mut NavAgent, &CompanionBehavior, &Transform), With<AiAgent>>,
navmesh: Res<NavMesh>,
) {
let player_pos = player.single().translation;
for (mut nav, behavior, transform) in companions.iter_mut() {
let distance = transform.translation.distance(player_pos);
if distance > behavior.follow_distance {
let target = calculate_follow_position(player_pos, transform.translation, behavior.follow_distance);
if let Some(path) = navmesh.find_path(transform.translation, target) {
nav.set_path(path);
}
} else {
nav.stop();
}
}
}
}
#![allow(unused)]
fn main() {
fn companion_combat_system(
mut companions: Query<(&AiAgent, &mut ActionQueue, &Transform, &Health)>,
enemies: Query<(Entity, &Transform, &Health), With<Enemy>>,
player: Query<&Transform, With<Player>>,
) {
let player_pos = player.single().translation;
for (agent, mut actions, companion_pos, health) in companions.iter_mut() {
if health.percentage() < agent.flee_threshold {
actions.push(Action::Flee);
continue;
}
let nearest_enemy = enemies.iter()
.filter(|(_, t, h)| h.current > 0.0)
.min_by_key(|(_, t, _)| {
(t.translation.distance(player_pos) * 100.0) as i32
});
if let Some((enemy_entity, enemy_pos, _)) = nearest_enemy {
let distance = companion_pos.translation.distance(enemy_pos.translation);
if distance < agent.engage_distance {
actions.push(Action::Attack(enemy_entity));
} else {
actions.push(Action::MoveTo(enemy_pos.translation));
}
}
}
}
}
#![allow(unused)]
fn main() {
fn companion_dialogue_system(
mut events: EventReader<DialogueRequest>,
mut companions: Query<(&AiAgent, &PerceptionState, &mut DialogueState)>,
llm: Res<LlmClient>,
mut responses: EventWriter<DialogueResponse>,
) {
for request in events.read() {
if let Ok((agent, perception, mut dialogue)) = companions.get_mut(request.target) {
let context = build_dialogue_context(agent, perception, &dialogue.history);
let prompt = format!(
"{}\n\nThe player says: '{}'\n\nRespond in character:",
context,
request.message
);
match llm.generate(&prompt) {
Ok(response) => {
dialogue.history.push(DialogueEntry {
speaker: "Player".into(),
text: request.message.clone(),
});
dialogue.history.push(DialogueEntry {
speaker: agent.name.clone(),
text: response.clone(),
});
responses.send(DialogueResponse {
speaker: agent.name.clone(),
text: response,
});
}
Err(_) => {
responses.send(DialogueResponse {
speaker: agent.name.clone(),
text: "Hmm, let me think about that...".into(),
});
}
}
}
}
}
}
Companions remember past events and use them in decision-making:
#![allow(unused)]
fn main() {
#[derive(Component)]
struct CompanionMemory {
short_term: VecDeque<MemoryEntry>,
long_term: Vec<MemoryEntry>,
capacity: usize,
}
impl CompanionMemory {
fn remember(&mut self, event: MemoryEntry) {
self.short_term.push_back(event);
if self.short_term.len() > self.capacity {
if let Some(old) = self.short_term.pop_front() {
if old.importance >= 0.7 {
self.long_term.push(old);
}
}
}
}
fn recall(&self, query: &str, limit: usize) -> Vec<&MemoryEntry> {
self.long_term.iter()
.chain(self.short_term.iter())
.filter(|m| m.matches(query))
.take(limit)
.collect()
}
}
#[derive(Clone)]
struct MemoryEntry {
timestamp: GameTime,
event_type: EventType,
description: String,
importance: f32,
entities: Vec<Entity>,
location: Vec3,
}
}
Players can issue commands to companions:
#![allow(unused)]
fn main() {
#[derive(Event)]
enum CompanionCommand {
Follow,
Stay,
Attack(Entity),
Investigate(Vec3),
UseAbility(AbilityId),
ToggleAggressive,
}
fn handle_companion_commands(
mut commands: EventReader<CompanionCommand>,
mut companions: Query<(&mut CompanionBehavior, &mut ActionQueue)>,
) {
for command in commands.read() {
for (mut behavior, mut actions) in companions.iter_mut() {
match command {
CompanionCommand::Follow => {
behavior.mode = CompanionMode::Follow;
actions.clear();
}
CompanionCommand::Stay => {
behavior.mode = CompanionMode::Stay;
actions.clear();
}
CompanionCommand::Attack(target) => {
actions.push(Action::Attack(*target));
}
CompanionCommand::Investigate(pos) => {
actions.push(Action::MoveTo(*pos));
actions.push(Action::Investigate);
}
CompanionCommand::UseAbility(ability) => {
actions.push(Action::UseAbility(*ability));
}
CompanionCommand::ToggleAggressive => {
behavior.aggressive = !behavior.aggressive;
}
}
}
}
}
}
#![allow(unused)]
fn main() {
pub fn create_healer_companion(world: &mut World) -> Entity {
spawn_companion(world, "Luna", "compassionate healer who prioritizes keeping allies alive")
.with(AvailableTools::new(vec![
Tool::heal(),
Tool::cure_status(),
Tool::buff(),
Tool::follow(),
Tool::flee(),
]))
.with(CompanionBehavior {
follow_distance: 5.0,
engage_distance: 15.0,
flee_health_threshold: 0.3,
mode: CompanionMode::Support,
})
}
pub fn create_tank_companion(world: &mut World) -> Entity {
spawn_companion(world, "Grimjaw", "fearless guardian who draws enemy attention")
.with(AvailableTools::new(vec![
Tool::taunt(),
Tool::block(),
Tool::attack(),
Tool::charge(),
Tool::protect_ally(),
]))
.with(CompanionBehavior {
follow_distance: 2.0,
engage_distance: 8.0,
flee_health_threshold: 0.1,
mode: CompanionMode::Aggressive,
})
}
}
- Limit perception radius for better performance
- Use plan caching to reduce LLM calls
- Batch companion updates in the scheduler
- Don't give companions tools they shouldn't have
- Always define fallback behaviors for LLM timeouts
- Test personality prompts for consistent behavior
Adaptive bosses in AstraWeave learn from player behavior and adjust their strategies in real-time. Unlike scripted boss encounters, these AI-driven enemies create unique, memorable fights that evolve with each attempt.
Bosses use the same AI architecture as companions - perception, planning, and validated tools - but with combat-focused behaviors and learning capabilities.
graph TB
subgraph "Boss AI"
Perception[Player Analysis]
Memory[Pattern Memory]
Strategy[Strategy Selection]
Phase[Phase Management]
Actions[Action Execution]
end
Player[Player Behavior] --> Perception
Perception --> Memory
Memory --> Strategy
Strategy --> Phase
Phase --> Actions
Actions --> Player
#![allow(unused)]
fn main() {
use astraweave_ai::prelude::*;
use astraweave_gameplay::boss::*;
fn spawn_adaptive_boss(world: &mut World) -> Entity {
world.spawn((
Name::new("The Hollow Knight"),
Transform::from_xyz(0.0, 2.0, 0.0),
BossAi::new()
.with_phases(3)
.with_adaptation_rate(0.3)
.with_pattern_memory(50),
BossHealth {
current: 10000.0,
max: 10000.0,
phase_thresholds: vec![0.7, 0.4, 0.15],
},
AvailableTools::new(vec![
Tool::melee_combo(),
Tool::ranged_attack(),
Tool::area_attack(),
Tool::summon_minions(),
Tool::teleport(),
Tool::enrage(),
]),
PlayerAnalyzer::default(),
StrategySelector::default(),
RigidBody::Dynamic,
Collider::capsule(1.0, 4.0),
NavAgent::default(),
))
}
}
#![allow(unused)]
fn main() {
#[derive(Component)]
struct BossPhaseConfig {
phases: Vec<BossPhase>,
current_phase: usize,
}
struct BossPhase {
name: String,
health_threshold: f32,
available_attacks: Vec<AttackPattern>,
behavior_modifiers: BehaviorModifiers,
transition_animation: Option<AnimationId>,
}
let phase_config = BossPhaseConfig {
phases: vec![
BossPhase {
name: "Cautious".into(),
health_threshold: 1.0,
available_attacks: vec![
AttackPattern::SingleSlash,
AttackPattern::Thrust,
AttackPattern::Sidestep,
],
behavior_modifiers: BehaviorModifiers {
aggression: 0.3,
defense: 0.7,
patience: 0.8,
},
transition_animation: None,
},
BossPhase {
name: "Aggressive".into(),
health_threshold: 0.6,
available_attacks: vec![
AttackPattern::TripleCombo,
AttackPattern::SpinAttack,
AttackPattern::LeapSlam,
AttackPattern::Thrust,
],
behavior_modifiers: BehaviorModifiers {
aggression: 0.7,
defense: 0.3,
patience: 0.4,
},
transition_animation: Some(AnimationId::Enrage),
},
BossPhase {
name: "Desperate".into(),
health_threshold: 0.25,
available_attacks: vec![
AttackPattern::FuryCombo,
AttackPattern::ShadowClones,
AttackPattern::AreaDenial,
AttackPattern::GrabAttack,
],
behavior_modifiers: BehaviorModifiers {
aggression: 1.0,
defense: 0.1,
patience: 0.1,
},
transition_animation: Some(AnimationId::Transform),
},
],
current_phase: 0,
};
}
Bosses track player behavior to counter their strategies:
#![allow(unused)]
fn main() {
#[derive(Component, Default)]
struct PlayerAnalyzer {
dodge_pattern: DodgePattern,
attack_timing: AttackTiming,
positioning_preference: PositioningStyle,
heal_threshold: f32,
aggression_level: f32,
pattern_history: VecDeque<PlayerAction>,
}
#[derive(Default)]
struct DodgePattern {
left_count: u32,
right_count: u32,
back_count: u32,
roll_timing: Vec<f32>,
}
fn analyze_player_system(
mut analyzers: Query<&mut PlayerAnalyzer, With<BossAi>>,
player_actions: EventReader<PlayerActionEvent>,
) {
for action in player_actions.read() {
for mut analyzer in analyzers.iter_mut() {
analyzer.pattern_history.push_back(action.clone());
if analyzer.pattern_history.len() > 100 {
analyzer.pattern_history.pop_front();
}
match action {
PlayerActionEvent::Dodge(direction) => {
match direction {
Direction::Left => analyzer.dodge_pattern.left_count += 1,
Direction::Right => analyzer.dodge_pattern.right_count += 1,
Direction::Back => analyzer.dodge_pattern.back_count += 1,
_ => {}
}
}
PlayerActionEvent::Attack(timing) => {
analyzer.attack_timing.record(*timing);
}
PlayerActionEvent::Heal => {
analyzer.heal_threshold = calculate_heal_threshold(&analyzer);
}
_ => {}
}
}
}
}
}
#![allow(unused)]
fn main() {
#[derive(Component)]
struct StrategySelector {
current_strategy: BossStrategy,
strategy_effectiveness: HashMap<BossStrategy, f32>,
adaptation_cooldown: Timer,
}
#[derive(Clone, Copy, Hash, Eq, PartialEq)]
enum BossStrategy {
Aggressive,
Defensive,
Counter,
Pressure,
Bait,
Mixed,
}
fn adapt_strategy_system(
mut bosses: Query<(&PlayerAnalyzer, &mut StrategySelector, &BossHealth)>,
time: Res<Time>,
) {
for (analyzer, mut selector, health) in bosses.iter_mut() {
selector.adaptation_cooldown.tick(time.delta());
if selector.adaptation_cooldown.finished() {
let new_strategy = select_counter_strategy(analyzer);
if new_strategy != selector.current_strategy {
let old_effectiveness = selector.strategy_effectiveness
.get(&selector.current_strategy)
.copied()
.unwrap_or(0.5);
selector.strategy_effectiveness
.insert(selector.current_strategy, old_effectiveness * 0.9);
selector.current_strategy = new_strategy;
selector.adaptation_cooldown.reset();
}
}
}
}
fn select_counter_strategy(analyzer: &PlayerAnalyzer) -> BossStrategy {
if analyzer.aggression_level > 0.7 {
BossStrategy::Counter
} else if analyzer.dodge_pattern.is_predictable() {
BossStrategy::Bait
} else if analyzer.heal_threshold > 0.5 {
BossStrategy::Pressure
} else {
BossStrategy::Mixed
}
}
}
#![allow(unused)]
fn main() {
fn boss_attack_system(
mut bosses: Query<(
&BossAi,
&StrategySelector,
&PlayerAnalyzer,
&Transform,
&mut ActionQueue,
)>,
player: Query<&Transform, With<Player>>,
) {
let player_pos = player.single().translation;
for (boss, strategy, analyzer, transform, mut actions) in bosses.iter_mut() {
let distance = transform.translation.distance(player_pos);
let attack = match strategy.current_strategy {
BossStrategy::Aggressive => {
select_aggressive_attack(distance, boss.current_phase)
}
BossStrategy::Counter => {
if analyzer.is_player_attacking() {
Some(AttackPattern::Parry)
} else {
Some(AttackPattern::Wait)
}
}
BossStrategy::Bait => {
let predicted_dodge = analyzer.predict_dodge_direction();
Some(AttackPattern::DelayedStrike(predicted_dodge.opposite()))
}
BossStrategy::Pressure => {
Some(AttackPattern::RelentlessCombo)
}
_ => {
select_random_attack(boss.current_phase)
}
};
if let Some(pattern) = attack {
actions.push(Action::ExecutePattern(pattern));
}
}
}
}
Bosses can remember strategies across player deaths:
#![allow(unused)]
fn main() {
#[derive(Resource)]
struct BossMemory {
player_deaths: u32,
successful_attacks: HashMap<AttackPattern, u32>,
failed_attacks: HashMap<AttackPattern, u32>,
player_weaknesses: Vec<Weakness>,
}
impl BossMemory {
fn record_attack_result(&mut self, pattern: AttackPattern, hit: bool) {
if hit {
*self.successful_attacks.entry(pattern).or_default() += 1;
} else {
*self.failed_attacks.entry(pattern).or_default() += 1;
}
}
fn get_attack_priority(&self, pattern: AttackPattern) -> f32 {
let successes = self.successful_attacks.get(&pattern).copied().unwrap_or(0) as f32;
let failures = self.failed_attacks.get(&pattern).copied().unwrap_or(0) as f32;
if successes + failures < 3.0 {
return 0.5;
}
successes / (successes + failures)
}
}
}
A complete adaptive boss implementation:
#![allow(unused)]
fn main() {
pub fn spawn_hollow_knight(world: &mut World) -> Entity {
let boss = world.spawn((
Name::new("The Hollow Knight"),
Transform::from_xyz(0.0, 2.0, -20.0),
BossAi::new()
.with_phases(4)
.with_adaptation_rate(0.25),
BossHealth::new(15000.0)
.with_phases(vec![0.75, 0.5, 0.25]),
PlayerAnalyzer::default(),
StrategySelector::new(BossStrategy::Defensive),
AttackPatterns::new(vec![
("slash", AttackPattern::Slash { damage: 80.0, range: 3.0 }),
("thrust", AttackPattern::Thrust { damage: 100.0, range: 5.0 }),
("spin", AttackPattern::Spin { damage: 60.0, radius: 4.0 }),
("leap", AttackPattern::LeapSlam { damage: 120.0, radius: 6.0 }),
("shadow", AttackPattern::ShadowDash { damage: 50.0, distance: 10.0 }),
]),
RigidBody::Dynamic,
Collider::capsule(1.2, 3.5),
NavAgent::default(),
));
world.send_event(BossSpawnedEvent { boss });
boss
}
}
- Start with simpler patterns, add complexity in later phases
- Ensure all attacks are telegraphed and fair
- Test adaptation rates - too fast feels unfair, too slow feels scripted
- Provide recovery windows between attack chains
- Adaptation should challenge, not frustrate
- Preserve attack patterns that players can learn
- Don't punish the same mistake indefinitely
- Allow multiple valid strategies
AstraWeave provides a unified crafting and combat framework that integrates with the AI systems for dynamic, adaptive gameplay. This guide covers implementing both systems with physics integration, AI-driven balancing, and procedural content generation.
graph TB
subgraph Combat["Combat System"]
CS[Combat State] --> DM[Damage Model]
DM --> HR[Hit Resolution]
HR --> EF[Effects Pipeline]
EF --> FB[Feedback System]
end
subgraph Crafting["Crafting System"]
IV[Inventory] --> RC[Recipe Check]
RC --> CR[Craft Resolution]
CR --> QR[Quality Roll]
QR --> IT[Item Creation]
end
subgraph Integration["AI Integration"]
AI[AI Director] --> CB[Combat Balancing]
AI --> CG[Craft Guidance]
CB --> CS
CG --> RC
end
Physics[Physics Engine] --> HR
Physics --> EF
Define the core components for combat entities:
#![allow(unused)]
fn main() {
use astraweave_ecs::prelude::*;
use astraweave_physics::prelude::*;
#[derive(Component, Debug, Clone)]
pub struct CombatStats {
pub health: f32,
pub max_health: f32,
pub stamina: f32,
pub max_stamina: f32,
pub poise: f32,
pub max_poise: f32,
}
impl Default for CombatStats {
fn default() -> Self {
Self {
health: 100.0,
max_health: 100.0,
stamina: 100.0,
max_stamina: 100.0,
poise: 50.0,
max_poise: 50.0,
}
}
}
#[derive(Component, Debug, Clone)]
pub struct DamageResistances {
pub physical: f32,
pub fire: f32,
pub ice: f32,
pub lightning: f32,
pub poison: f32,
}
#[derive(Component, Debug, Clone)]
pub struct WeaponStats {
pub base_damage: f32,
pub damage_type: DamageType,
pub attack_speed: f32,
pub reach: f32,
pub stamina_cost: f32,
pub poise_damage: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DamageType {
Physical,
Fire,
Ice,
Lightning,
Poison,
}
#[derive(Component, Debug)]
pub struct CombatState {
pub stance: CombatStance,
pub combo_count: u32,
pub combo_timer: f32,
pub stagger_timer: f32,
pub invincibility_frames: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CombatStance {
Neutral,
Attacking,
Blocking,
Dodging,
Staggered,
Recovering,
}
}
Implement the attack resolution with physics-based hit detection:
#![allow(unused)]
fn main() {
use astraweave_physics::collision::*;
#[derive(Debug, Clone)]
pub struct AttackEvent {
pub attacker: Entity,
pub weapon: Entity,
pub attack_type: AttackType,
pub direction: Vec3,
}
#[derive(Debug, Clone, Copy)]
pub enum AttackType {
Light,
Heavy,
Special,
Charged { charge_time: f32 },
}
pub fn attack_system(
mut commands: Commands,
mut attack_events: EventReader<AttackEvent>,
mut combat_query: Query<(&mut CombatState, &mut CombatStats, &Transform)>,
weapon_query: Query<&WeaponStats>,
physics: Res<PhysicsWorld>,
) {
for event in attack_events.iter() {
let Ok((mut state, mut stats, transform)) = combat_query.get_mut(event.attacker) else {
continue;
};
if stats.stamina < get_stamina_cost(&event.attack_type) {
continue;
}
let Ok(weapon) = weapon_query.get(event.weapon) else {
continue;
};
stats.stamina -= weapon.stamina_cost * get_attack_multiplier(&event.attack_type);
state.stance = CombatStance::Attacking;
let hitbox = create_attack_hitbox(transform, weapon, &event.attack_type);
commands.spawn((
hitbox,
AttackHitbox {
owner: event.attacker,
damage: calculate_attack_damage(weapon, &event.attack_type, state.combo_count),
damage_type: weapon.damage_type,
poise_damage: weapon.poise_damage,
knockback: event.direction * 5.0,
},
Lifetime { remaining: 0.2 },
));
state.combo_count = (state.combo_count + 1).min(5);
state.combo_timer = 1.5;
}
}
fn calculate_attack_damage(weapon: &WeaponStats, attack_type: &AttackType, combo: u32) -> f32 {
let base = weapon.base_damage;
let type_mult = match attack_type {
AttackType::Light => 1.0,
AttackType::Heavy => 1.8,
AttackType::Special => 2.5,
AttackType::Charged { charge_time } => 1.0 + (charge_time * 0.5).min(2.0),
};
let combo_mult = 1.0 + (combo as f32 * 0.1);
base * type_mult * combo_mult
}
fn create_attack_hitbox(
transform: &Transform,
weapon: &WeaponStats,
attack_type: &AttackType,
) -> Collider {
let size = match attack_type {
AttackType::Light => Vec3::new(weapon.reach * 0.8, 1.0, weapon.reach * 0.8),
AttackType::Heavy => Vec3::new(weapon.reach * 1.2, 1.5, weapon.reach * 1.2),
AttackType::Special => Vec3::new(weapon.reach * 2.0, 2.0, weapon.reach * 2.0),
AttackType::Charged { .. } => Vec3::new(weapon.reach * 1.5, 1.2, weapon.reach * 1.5),
};
Collider::cuboid(size.x, size.y, size.z)
.with_offset(transform.forward() * (weapon.reach * 0.5))
}
}
Process hits and apply damage with resistance calculations:
#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct AttackHitbox {
pub owner: Entity,
pub damage: f32,
pub damage_type: DamageType,
pub poise_damage: f32,
pub knockback: Vec3,
}
pub fn damage_resolution_system(
mut commands: Commands,
hitbox_query: Query<(Entity, &AttackHitbox, &CollidingEntities)>,
mut target_query: Query<(
&mut CombatStats,
&mut CombatState,
&DamageResistances,
&mut Transform,
)>,
mut damage_events: EventWriter<DamageEvent>,
) {
for (hitbox_entity, hitbox, colliding) in hitbox_query.iter() {
for &target in colliding.iter() {
if target == hitbox.owner {
continue;
}
let Ok((mut stats, mut state, resistances, mut transform)) =
target_query.get_mut(target) else {
continue;
};
if state.invincibility_frames > 0.0 {
continue;
}
let resistance = get_resistance(resistances, hitbox.damage_type);
let final_damage = hitbox.damage * (1.0 - resistance);
stats.health = (stats.health - final_damage).max(0.0);
stats.poise -= hitbox.poise_damage;
if stats.poise <= 0.0 {
state.stance = CombatStance::Staggered;
state.stagger_timer = 1.5;
stats.poise = stats.max_poise * 0.5;
}
transform.translation += hitbox.knockback * 0.1;
damage_events.send(DamageEvent {
target,
attacker: hitbox.owner,
damage: final_damage,
damage_type: hitbox.damage_type,
was_critical: false,
});
state.invincibility_frames = 0.15;
}
commands.entity(hitbox_entity).despawn();
}
}
fn get_resistance(resistances: &DamageResistances, damage_type: DamageType) -> f32 {
match damage_type {
DamageType::Physical => resistances.physical,
DamageType::Fire => resistances.fire,
DamageType::Ice => resistances.ice,
DamageType::Lightning => resistances.lightning,
DamageType::Poison => resistances.poison,
}.clamp(0.0, 0.9)
}
}
Leverage the AI system for dynamic combat behavior:
#![allow(unused)]
fn main() {
use astraweave_ai::prelude::*;
#[derive(Component)]
pub struct CombatAI {
pub aggression: f32,
pub patience: f32,
pub preferred_range: f32,
pub combo_tendency: f32,
}
pub fn ai_combat_decision_system(
mut ai_query: Query<(
Entity,
&CombatAI,
&CombatStats,
&CombatState,
&Transform,
&mut AiPlanner,
)>,
target_query: Query<(&CombatStats, &CombatState, &Transform), Without<CombatAI>>,
mut attack_events: EventWriter<AttackEvent>,
) {
for (entity, ai, stats, state, transform, mut planner) in ai_query.iter_mut() {
if state.stance != CombatStance::Neutral {
continue;
}
let Some(target) = find_nearest_target(transform, &target_query) else {
continue;
};
let (target_stats, target_state, target_transform) = target_query.get(target).unwrap();
let distance = transform.translation.distance(target_transform.translation);
let direction = (target_transform.translation - transform.translation).normalize();
let decision = evaluate_combat_options(
ai,
stats,
target_stats,
target_state,
distance,
);
match decision {
CombatDecision::Attack(attack_type) => {
attack_events.send(AttackEvent {
attacker: entity,
weapon: entity,
attack_type,
direction,
});
}
CombatDecision::Reposition(target_distance) => {
let goal_pos = target_transform.translation - direction * target_distance;
planner.set_goal(AiGoal::MoveTo { position: goal_pos });
}
CombatDecision::Defend => {
planner.set_goal(AiGoal::Custom("block".into()));
}
CombatDecision::Wait => {}
}
}
}
#[derive(Debug)]
enum CombatDecision {
Attack(AttackType),
Reposition(f32),
Defend,
Wait,
}
fn evaluate_combat_options(
ai: &CombatAI,
stats: &CombatStats,
target_stats: &CombatStats,
target_state: &CombatState,
distance: f32,
) -> CombatDecision {
let health_ratio = stats.health / stats.max_health;
let stamina_ratio = stats.stamina / stats.max_stamina;
let target_health_ratio = target_stats.health / target_stats.max_health;
if target_state.stance == CombatStance::Staggered && distance < 3.0 {
return CombatDecision::Attack(AttackType::Heavy);
}
if health_ratio < 0.3 && ai.patience > 0.5 {
return CombatDecision::Reposition(ai.preferred_range * 1.5);
}
if distance > ai.preferred_range * 1.2 {
return CombatDecision::Reposition(ai.preferred_range);
}
if target_state.stance == CombatStance::Attacking && ai.patience > 0.7 {
return CombatDecision::Defend;
}
if stamina_ratio > 0.3 && distance < ai.preferred_range {
let attack_type = if ai.aggression > 0.7 && stamina_ratio > 0.6 {
AttackType::Heavy
} else {
AttackType::Light
};
return CombatDecision::Attack(attack_type);
}
CombatDecision::Wait
}
}
Define the item and inventory structures:
#![allow(unused)]
fn main() {
#[derive(Component, Debug, Clone)]
pub struct Item {
pub id: ItemId,
pub name: String,
pub item_type: ItemType,
pub rarity: ItemRarity,
pub stack_size: u32,
pub max_stack: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ItemId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ItemType {
Material,
Consumable,
Weapon,
Armor,
Accessory,
Tool,
QuestItem,
}
#[derive(Debug, Clone, Copy, PartialEq, Ord, PartialOrd, Eq)]
pub enum ItemRarity {
Common,
Uncommon,
Rare,
Epic,
Legendary,
}
#[derive(Component, Debug)]
pub struct Inventory {
pub slots: Vec<Option<InventorySlot>>,
pub max_slots: usize,
pub weight_limit: f32,
pub current_weight: f32,
}
#[derive(Debug, Clone)]
pub struct InventorySlot {
pub item: Item,
pub quantity: u32,
}
impl Inventory {
pub fn new(max_slots: usize, weight_limit: f32) -> Self {
Self {
slots: vec![None; max_slots],
max_slots,
weight_limit,
current_weight: 0.0,
}
}
pub fn add_item(&mut self, item: Item, quantity: u32) -> Result<(), InventoryError> {
for slot in self.slots.iter_mut() {
if let Some(ref mut existing) = slot {
if existing.item.id == item.id && existing.quantity < item.max_stack {
let space = item.max_stack - existing.quantity;
let add = quantity.min(space);
existing.quantity += add;
if add == quantity {
return Ok(());
}
}
}
}
for slot in self.slots.iter_mut() {
if slot.is_none() {
*slot = Some(InventorySlot {
item: item.clone(),
quantity: quantity.min(item.max_stack),
});
return Ok(());
}
}
Err(InventoryError::Full)
}
pub fn remove_item(&mut self, item_id: ItemId, quantity: u32) -> Result<u32, InventoryError> {
let mut remaining = quantity;
for slot in self.slots.iter_mut() {
if remaining == 0 {
break;
}
if let Some(ref mut existing) = slot {
if existing.item.id == item_id {
let remove = remaining.min(existing.quantity);
existing.quantity -= remove;
remaining -= remove;
if existing.quantity == 0 {
*slot = None;
}
}
}
}
if remaining > 0 {
Err(InventoryError::InsufficientItems)
} else {
Ok(quantity)
}
}
pub fn count_item(&self, item_id: ItemId) -> u32 {
self.slots
.iter()
.filter_map(|s| s.as_ref())
.filter(|s| s.item.id == item_id)
.map(|s| s.quantity)
.sum()
}
}
#[derive(Debug)]
pub enum InventoryError {
Full,
InsufficientItems,
WeightExceeded,
}
}
Define recipes and crafting requirements:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub struct Recipe {
pub id: RecipeId,
pub name: String,
pub ingredients: Vec<RecipeIngredient>,
pub outputs: Vec<RecipeOutput>,
pub crafting_time: f32,
pub required_station: Option<CraftingStation>,
pub required_skill: Option<(SkillType, u32)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RecipeId(pub u64);
#[derive(Debug, Clone)]
pub struct RecipeIngredient {
pub item_id: ItemId,
pub quantity: u32,
pub consumed: bool,
}
#[derive(Debug, Clone)]
pub struct RecipeOutput {
pub item_id: ItemId,
pub base_quantity: u32,
pub quality_scaling: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CraftingStation {
Workbench,
Forge,
Alchemy,
Enchanting,
Cooking,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SkillType {
Smithing,
Alchemy,
Enchanting,
Cooking,
Woodworking,
}
#[derive(Resource)]
pub struct RecipeRegistry {
recipes: HashMap<RecipeId, Recipe>,
by_station: HashMap<Option<CraftingStation>, Vec<RecipeId>>,
by_output: HashMap<ItemId, Vec<RecipeId>>,
}
impl RecipeRegistry {
pub fn new() -> Self {
Self {
recipes: HashMap::new(),
by_station: HashMap::new(),
by_output: HashMap::new(),
}
}
pub fn register(&mut self, recipe: Recipe) {
let id = recipe.id;
self.by_station
.entry(recipe.required_station)
.or_default()
.push(id);
for output in &recipe.outputs {
self.by_output
.entry(output.item_id)
.or_default()
.push(id);
}
self.recipes.insert(id, recipe);
}
pub fn get(&self, id: RecipeId) -> Option<&Recipe> {
self.recipes.get(&id)
}
pub fn available_at_station(&self, station: Option<CraftingStation>) -> &[RecipeId] {
self.by_station.get(&station).map(|v| v.as_slice()).unwrap_or(&[])
}
pub fn recipes_for_item(&self, item_id: ItemId) -> &[RecipeId] {
self.by_output.get(&item_id).map(|v| v.as_slice()).unwrap_or(&[])
}
}
}
Process crafting with quality calculations:
#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct CraftRequest {
pub crafter: Entity,
pub recipe_id: RecipeId,
pub station: Option<Entity>,
}
#[derive(Debug)]
pub struct CraftResult {
pub success: bool,
pub items: Vec<(ItemId, u32, ItemRarity)>,
pub experience: u32,
}
pub fn crafting_system(
mut craft_requests: EventReader<CraftRequest>,
mut craft_results: EventWriter<CraftResult>,
registry: Res<RecipeRegistry>,
mut crafter_query: Query<(&mut Inventory, Option<&CrafterSkills>)>,
station_query: Query<&CraftingStationComponent>,
item_registry: Res<ItemRegistry>,
) {
for request in craft_requests.iter() {
let Some(recipe) = registry.get(request.recipe_id) else {
continue;
};
let Ok((mut inventory, skills)) = crafter_query.get_mut(request.crafter) else {
continue;
};
if let Some(station_entity) = request.station {
if let Ok(station) = station_query.get(station_entity) {
if Some(station.station_type) != recipe.required_station {
continue;
}
}
} else if recipe.required_station.is_some() {
continue;
}
if !can_craft(&inventory, recipe) {
continue;
}
if let Some((skill_type, level)) = recipe.required_skill {
let crafter_level = skills
.map(|s| s.get_level(skill_type))
.unwrap_or(0);
if crafter_level < level {
continue;
}
}
for ingredient in &recipe.ingredients {
if ingredient.consumed {
let _ = inventory.remove_item(ingredient.item_id, ingredient.quantity);
}
}
let quality = calculate_craft_quality(skills, recipe);
let rarity = quality_to_rarity(quality);
let mut crafted_items = Vec::new();
for output in &recipe.outputs {
let quantity = if output.quality_scaling {
(output.base_quantity as f32 * (1.0 + quality * 0.5)) as u32
} else {
output.base_quantity
};
if let Some(base_item) = item_registry.get(output.item_id) {
let mut item = base_item.clone();
item.rarity = rarity;
let _ = inventory.add_item(item, quantity);
crafted_items.push((output.item_id, quantity, rarity));
}
}
craft_results.send(CraftResult {
success: true,
items: crafted_items,
experience: calculate_craft_xp(recipe, quality),
});
}
}
fn can_craft(inventory: &Inventory, recipe: &Recipe) -> bool {
recipe.ingredients.iter().all(|ing| {
inventory.count_item(ing.item_id) >= ing.quantity
})
}
fn calculate_craft_quality(skills: Option<&CrafterSkills>, recipe: &Recipe) -> f32 {
let base_quality = 0.5;
let skill_bonus = if let (Some(skills), Some((skill_type, required))) = (skills, recipe.required_skill) {
let level = skills.get_level(skill_type) as f32;
let excess = (level - required as f32).max(0.0);
excess * 0.05
} else {
0.0
};
let random_factor = rand::random::<f32>() * 0.2;
(base_quality + skill_bonus + random_factor).clamp(0.0, 1.0)
}
fn quality_to_rarity(quality: f32) -> ItemRarity {
match quality {
q if q >= 0.95 => ItemRarity::Legendary,
q if q >= 0.8 => ItemRarity::Epic,
q if q >= 0.6 => ItemRarity::Rare,
q if q >= 0.4 => ItemRarity::Uncommon,
_ => ItemRarity::Common,
}
}
}
Use AI to suggest recipes and optimize crafting:
#![allow(unused)]
fn main() {
use astraweave_llm::prelude::*;
pub struct CraftingAdvisor {
llm: LlmClient,
}
impl CraftingAdvisor {
pub async fn suggest_recipes(
&self,
inventory: &Inventory,
goal: &str,
registry: &RecipeRegistry,
) -> Vec<RecipeSuggestion> {
let available_items: Vec<_> = inventory.slots
.iter()
.filter_map(|s| s.as_ref())
.map(|s| format!("{} x{}", s.item.name, s.quantity))
.collect();
let prompt = format!(
r#"Given these materials: {}
Player goal: {}
Suggest the best crafting recipes to achieve this goal.
Return as JSON array of recipe suggestions."#,
available_items.join(", "),
goal
);
let response = self.llm.complete(&prompt).await;
parse_suggestions(&response, registry)
}
pub async fn optimize_craft_order(
&self,
target_item: ItemId,
inventory: &Inventory,
registry: &RecipeRegistry,
) -> Vec<RecipeId> {
let mut order = Vec::new();
let mut simulated_inventory = inventory.clone();
self.build_craft_tree(target_item, &mut simulated_inventory, registry, &mut order);
order
}
fn build_craft_tree(
&self,
target: ItemId,
inventory: &mut Inventory,
registry: &RecipeRegistry,
order: &mut Vec<RecipeId>,
) {
let recipes = registry.recipes_for_item(target);
for &recipe_id in recipes {
if let Some(recipe) = registry.get(recipe_id) {
let mut can_craft = true;
for ingredient in &recipe.ingredients {
let have = inventory.count_item(ingredient.item_id);
if have < ingredient.quantity {
self.build_craft_tree(
ingredient.item_id,
inventory,
registry,
order,
);
if inventory.count_item(ingredient.item_id) < ingredient.quantity {
can_craft = false;
break;
}
}
}
if can_craft {
order.push(recipe_id);
return;
}
}
}
}
}
#[derive(Debug)]
pub struct RecipeSuggestion {
pub recipe_id: RecipeId,
pub reason: String,
pub priority: u32,
}
}
Allow runtime weapon customization:
#![allow(unused)]
fn main() {
#[derive(Component, Debug)]
pub struct ModifiableWeapon {
pub base: WeaponStats,
pub modifications: Vec<WeaponMod>,
pub max_mods: usize,
}
#[derive(Debug, Clone)]
pub struct WeaponMod {
pub mod_type: WeaponModType,
pub value: f32,
pub item_source: ItemId,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum WeaponModType {
DamageBonus,
ElementalDamage(DamageType),
AttackSpeed,
CriticalChance,
CriticalDamage,
LifeSteal,
PoiseDamage,
}
impl ModifiableWeapon {
pub fn calculate_stats(&self) -> WeaponStats {
let mut stats = self.base.clone();
for modification in &self.modifications {
match modification.mod_type {
WeaponModType::DamageBonus => {
stats.base_damage *= 1.0 + modification.value;
}
WeaponModType::AttackSpeed => {
stats.attack_speed *= 1.0 + modification.value;
}
WeaponModType::PoiseDamage => {
stats.poise_damage *= 1.0 + modification.value;
}
_ => {}
}
}
stats
}
pub fn add_mod(&mut self, modification: WeaponMod) -> Result<(), &'static str> {
if self.modifications.len() >= self.max_mods {
return Err("Maximum modifications reached");
}
let same_type_count = self.modifications
.iter()
.filter(|m| std::mem::discriminant(&m.mod_type) == std::mem::discriminant(&modification.mod_type))
.count();
if same_type_count >= 2 {
return Err("Cannot stack more than 2 of the same mod type");
}
self.modifications.push(modification);
Ok(())
}
}
pub fn apply_weapon_mods_system(
mut weapons: Query<(&ModifiableWeapon, &mut WeaponStats), Changed<ModifiableWeapon>>,
) {
for (modifiable, mut stats) in weapons.iter_mut() {
*stats = modifiable.calculate_stats();
}
}
}
Generate loot based on combat performance:
#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct LootTable {
pub entries: Vec<LootEntry>,
pub guaranteed: Vec<ItemId>,
}
#[derive(Debug, Clone)]
pub struct LootEntry {
pub item_id: ItemId,
pub weight: f32,
pub min_quantity: u32,
pub max_quantity: u32,
pub min_rarity: ItemRarity,
}
pub fn combat_loot_system(
mut death_events: EventReader<EntityDeathEvent>,
loot_query: Query<&LootTable>,
combat_log: Res<CombatLog>,
mut loot_events: EventWriter<LootDropEvent>,
) {
for event in death_events.iter() {
let Ok(loot_table) = loot_query.get(event.entity) else {
continue;
};
let performance = combat_log.get_performance(event.killer, event.entity);
let luck_bonus = calculate_luck_bonus(&performance);
let mut drops = Vec::new();
for item_id in &loot_table.guaranteed {
drops.push((*item_id, 1, ItemRarity::Common));
}
let roll_count = 1 + (luck_bonus * 2.0) as usize;
for _ in 0..roll_count {
if let Some(entry) = roll_loot(loot_table, luck_bonus) {
let quantity = rand::thread_rng()
.gen_range(entry.min_quantity..=entry.max_quantity);
let rarity = roll_rarity(entry.min_rarity, luck_bonus);
drops.push((entry.item_id, quantity, rarity));
}
}
loot_events.send(LootDropEvent {
position: event.position,
drops,
});
}
}
fn calculate_luck_bonus(performance: &CombatPerformance) -> f32 {
let mut bonus = 0.0;
if performance.no_damage_taken {
bonus += 0.3;
}
if performance.time_to_kill < 30.0 {
bonus += 0.2;
}
if performance.parries > 0 {
bonus += performance.parries as f32 * 0.05;
}
bonus.min(1.0)
}
}
1. **Balance Stamina Economy**: Ensure attacks have meaningful stamina costs to create decision points
2. **Poise Windows**: Use stagger states to create openings for both players and AI
3. **Readable Attacks**: Give clear visual tells before powerful attacks
4. **Combo Depth**: Reward skill with combo multipliers but keep basic attacks viable
- **Damage Sponges**: Avoid enemies that just have more health; vary resistances and mechanics
- **Inventory Overflow**: Always handle full inventory gracefully
- **Recipe Bloat**: Curate recipes to avoid overwhelming players
- **Mod Stacking**: Prevent multiplicative mod abuse
AstraWeave’s dialogue system combines traditional branching dialogue with LLM-powered dynamic conversations, enabling NPCs to engage in contextual, personality-driven interactions while maintaining narrative coherence.
graph TB
subgraph Input["Player Input"]
PI[Player Choice] --> DM
TI[Text Input] --> NLP[NLP Parser]
NLP --> DM
end
subgraph DialogueManager["Dialogue Manager"]
DM[Dialogue Controller] --> SC[Script Check]
SC -->|Scripted| SN[Script Node]
SC -->|Dynamic| LLM[LLM Handler]
SN --> VAL[Validator]
LLM --> VAL
VAL --> OUT[Output]
end
subgraph Context["Context System"]
MEM[Memory] --> DM
REL[Relationships] --> DM
QS[Quest State] --> DM
WS[World State] --> DM
end
OUT --> UI[Dialogue UI]
OUT --> AUD[Voice/Audio]
OUT --> EVT[Event Triggers]
Define structured dialogue with branching paths:
#![allow(unused)]
fn main() {
use astraweave_ecs::prelude::*;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct DialogueTree {
pub id: DialogueId,
pub nodes: HashMap<NodeId, DialogueNode>,
pub entry_node: NodeId,
pub metadata: DialogueMetadata,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct DialogueId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NodeId(pub u32);
#[derive(Debug, Clone)]
pub struct DialogueNode {
pub id: NodeId,
pub speaker: SpeakerId,
pub content: DialogueContent,
pub responses: Vec<DialogueResponse>,
pub conditions: Vec<DialogueCondition>,
pub effects: Vec<DialogueEffect>,
}
#[derive(Debug, Clone)]
pub enum DialogueContent {
Text(String),
Localized { key: String, fallback: String },
Dynamic { prompt_template: String },
Voice { text: String, audio_id: String },
}
#[derive(Debug, Clone)]
pub struct DialogueResponse {
pub text: String,
pub next_node: Option<NodeId>,
pub conditions: Vec<DialogueCondition>,
pub effects: Vec<DialogueEffect>,
pub tone: ResponseTone,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ResponseTone {
Neutral,
Friendly,
Hostile,
Curious,
Sarcastic,
Romantic,
}
#[derive(Debug, Clone)]
pub enum DialogueCondition {
QuestState { quest_id: u64, state: String },
RelationshipMin { npc_id: u64, value: i32 },
HasItem { item_id: u64, quantity: u32 },
PlayerStat { stat: String, min: i32 },
Flag { name: String, value: bool },
TimeOfDay { start: u32, end: u32 },
Custom { script: String },
}
#[derive(Debug, Clone)]
pub enum DialogueEffect {
SetFlag { name: String, value: bool },
ModifyRelationship { npc_id: u64, delta: i32 },
GiveItem { item_id: u64, quantity: u32 },
TakeItem { item_id: u64, quantity: u32 },
StartQuest { quest_id: u64 },
AdvanceQuest { quest_id: u64, stage: String },
TriggerEvent { event_name: String },
PlayAnimation { animation: String },
}
}
Manage dialogue state and flow:
#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct DialogueController {
pub active_dialogue: Option<ActiveDialogue>,
pub history: Vec<DialogueHistoryEntry>,
pub context: DialogueContext,
}
#[derive(Debug)]
pub struct ActiveDialogue {
pub tree: DialogueTree,
pub current_node: NodeId,
pub participants: Vec<Entity>,
pub started_at: f64,
}
#[derive(Debug, Clone)]
pub struct DialogueHistoryEntry {
pub node_id: NodeId,
pub speaker: SpeakerId,
pub text: String,
pub timestamp: f64,
pub response_chosen: Option<usize>,
}
#[derive(Debug, Default)]
pub struct DialogueContext {
pub player_name: String,
pub location: String,
pub time_of_day: String,
pub recent_events: Vec<String>,
pub custom_vars: HashMap<String, String>,
}
impl DialogueController {
pub fn start_dialogue(&mut self, tree: DialogueTree, participants: Vec<Entity>, time: f64) {
self.active_dialogue = Some(ActiveDialogue {
current_node: tree.entry_node,
tree,
participants,
started_at: time,
});
}
pub fn get_current_node(&self) -> Option<&DialogueNode> {
self.active_dialogue.as_ref().and_then(|active| {
active.tree.nodes.get(&active.current_node)
})
}
pub fn select_response(&mut self, index: usize, time: f64) -> Option<Vec<DialogueEffect>> {
let active = self.active_dialogue.as_mut()?;
let node = active.tree.nodes.get(&active.current_node)?;
let response = node.responses.get(index)?;
self.history.push(DialogueHistoryEntry {
node_id: active.current_node,
speaker: node.speaker,
text: match &node.content {
DialogueContent::Text(t) => t.clone(),
DialogueContent::Localized { fallback, .. } => fallback.clone(),
DialogueContent::Dynamic { .. } => "[Dynamic]".into(),
DialogueContent::Voice { text, .. } => text.clone(),
},
timestamp: time,
response_chosen: Some(index),
});
if let Some(next) = response.next_node {
active.current_node = next;
Some(response.effects.clone())
} else {
let effects = response.effects.clone();
self.active_dialogue = None;
Some(effects)
}
}
pub fn end_dialogue(&mut self) {
self.active_dialogue = None;
}
}
}
Process dialogue logic each frame:
#![allow(unused)]
fn main() {
pub fn dialogue_system(
mut dialogue_query: Query<&mut DialogueController>,
mut effect_events: EventWriter<DialogueEffectEvent>,
conditions: Res<ConditionEvaluator>,
time: Res<Time>,
) {
for mut controller in dialogue_query.iter_mut() {
let Some(active) = &controller.active_dialogue else {
continue;
};
let Some(node) = active.tree.nodes.get(&active.current_node) else {
controller.end_dialogue();
continue;
};
if !conditions.evaluate_all(&node.conditions) {
if let Some(fallback) = find_fallback_node(&active.tree, &conditions) {
if let Some(active) = controller.active_dialogue.as_mut() {
active.current_node = fallback;
}
} else {
controller.end_dialogue();
}
continue;
}
for effect in &node.effects {
effect_events.send(DialogueEffectEvent {
effect: effect.clone(),
timestamp: time.elapsed_seconds_f64(),
});
}
}
}
pub fn dialogue_effect_system(
mut effect_events: EventReader<DialogueEffectEvent>,
mut quest_events: EventWriter<QuestEvent>,
mut inventory_events: EventWriter<InventoryEvent>,
mut relationship_events: EventWriter<RelationshipEvent>,
mut flags: ResMut<GameFlags>,
) {
for event in effect_events.iter() {
match &event.effect {
DialogueEffect::SetFlag { name, value } => {
flags.set(name.clone(), *value);
}
DialogueEffect::ModifyRelationship { npc_id, delta } => {
relationship_events.send(RelationshipEvent::Modify {
npc_id: *npc_id,
delta: *delta,
});
}
DialogueEffect::GiveItem { item_id, quantity } => {
inventory_events.send(InventoryEvent::Add {
item_id: *item_id,
quantity: *quantity,
});
}
DialogueEffect::StartQuest { quest_id } => {
quest_events.send(QuestEvent::Start { quest_id: *quest_id });
}
DialogueEffect::AdvanceQuest { quest_id, stage } => {
quest_events.send(QuestEvent::Advance {
quest_id: *quest_id,
stage: stage.clone(),
});
}
_ => {}
}
}
}
}
Enable free-form AI conversations:
#![allow(unused)]
fn main() {
use astraweave_llm::prelude::*;
#[derive(Component)]
pub struct DynamicDialogue {
pub npc_persona: NpcPersona,
pub conversation_history: Vec<ConversationTurn>,
pub memory_context: Vec<String>,
pub allowed_topics: Vec<String>,
pub forbidden_topics: Vec<String>,
pub max_history: usize,
}
#[derive(Debug, Clone)]
pub struct NpcPersona {
pub name: String,
pub personality: String,
pub background: String,
pub speaking_style: String,
pub knowledge: Vec<String>,
pub goals: Vec<String>,
pub relationships: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct ConversationTurn {
pub role: ConversationRole,
pub content: String,
pub timestamp: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ConversationRole {
Player,
Npc,
System,
}
impl DynamicDialogue {
pub fn new(persona: NpcPersona) -> Self {
Self {
npc_persona: persona,
conversation_history: Vec::new(),
memory_context: Vec::new(),
allowed_topics: Vec::new(),
forbidden_topics: Vec::new(),
max_history: 20,
}
}
pub fn add_turn(&mut self, role: ConversationRole, content: String, time: f64) {
self.conversation_history.push(ConversationTurn {
role,
content,
timestamp: time,
});
if self.conversation_history.len() > self.max_history {
self.conversation_history.remove(0);
}
}
pub fn build_prompt(&self, player_input: &str, context: &DialogueContext) -> String {
let mut prompt = format!(
r#"You are {}, an NPC in a fantasy game.
Personality: {}
Background: {}
Speaking style: {}
Current situation:
- Location: {}
- Time: {}
- Recent events: {}
Knowledge you have:
{}
Your goals:
{}
"#,
self.npc_persona.name,
self.npc_persona.personality,
self.npc_persona.background,
self.npc_persona.speaking_style,
context.location,
context.time_of_day,
context.recent_events.join(", "),
self.npc_persona.knowledge.join("\n- "),
self.npc_persona.goals.join("\n- "),
);
if !self.memory_context.is_empty() {
prompt.push_str("Relevant memories:\n");
for memory in &self.memory_context {
prompt.push_str(&format!("- {}\n", memory));
}
prompt.push('\n');
}
prompt.push_str("Conversation so far:\n");
for turn in &self.conversation_history {
let speaker = match turn.role {
ConversationRole::Player => &context.player_name,
ConversationRole::Npc => &self.npc_persona.name,
ConversationRole::System => "[System]",
};
prompt.push_str(&format!("{}: {}\n", speaker, turn.content));
}
prompt.push_str(&format!("\n{}: {}\n", context.player_name, player_input));
prompt.push_str(&format!("\nRespond as {}. Stay in character. Keep response under 100 words.",
self.npc_persona.name));
prompt
}
}
}
Process dynamic dialogue requests:
#![allow(unused)]
fn main() {
pub struct LlmDialogueSystem {
llm: LlmClient,
validator: DialogueValidator,
}
impl LlmDialogueSystem {
pub async fn generate_response(
&self,
dialogue: &mut DynamicDialogue,
player_input: &str,
context: &DialogueContext,
) -> Result<String, DialogueError> {
dialogue.add_turn(
ConversationRole::Player,
player_input.to_string(),
0.0
);
let prompt = dialogue.build_prompt(player_input, context);
let response = self.llm.complete(&prompt).await
.map_err(|e| DialogueError::LlmError(e.to_string()))?;
let validated = self.validator.validate_response(
&response,
&dialogue.forbidden_topics,
&dialogue.npc_persona,
)?;
dialogue.add_turn(ConversationRole::Npc, validated.clone(), 0.0);
Ok(validated)
}
}
pub struct DialogueValidator {
blocked_patterns: Vec<regex::Regex>,
}
impl DialogueValidator {
pub fn validate_response(
&self,
response: &str,
forbidden_topics: &[String],
persona: &NpcPersona,
) -> Result<String, DialogueError> {
if response.len() > 500 {
return Err(DialogueError::ResponseTooLong);
}
for pattern in &self.blocked_patterns {
if pattern.is_match(response) {
return Err(DialogueError::BlockedContent);
}
}
for topic in forbidden_topics {
if response.to_lowercase().contains(&topic.to_lowercase()) {
return Err(DialogueError::ForbiddenTopic(topic.clone()));
}
}
Ok(response.trim().to_string())
}
}
#[derive(Debug)]
pub enum DialogueError {
LlmError(String),
ResponseTooLong,
BlockedContent,
ForbiddenTopic(String),
ValidationFailed,
}
}
Allow NPCs to take actions during conversation:
#![allow(unused)]
fn main() {
use astraweave_ai::tools::*;
pub fn create_dialogue_tools() -> Vec<AiTool> {
vec![
AiTool {
name: "give_item".into(),
description: "Give an item to the player as a gift or quest reward".into(),
parameters: json!({
"type": "object",
"properties": {
"item_name": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1 },
"reason": { "type": "string" }
},
"required": ["item_name", "quantity"]
}),
handler: Box::new(|params| {
let item = params["item_name"].as_str().unwrap();
let qty = params["quantity"].as_i64().unwrap() as u32;
ToolResult::Success(json!({
"action": "give_item",
"item": item,
"quantity": qty
}))
}),
},
AiTool {
name: "share_rumor".into(),
description: "Share a rumor or piece of information with the player".into(),
parameters: json!({
"type": "object",
"properties": {
"rumor_id": { "type": "string" },
"importance": { "type": "string", "enum": ["minor", "major", "critical"] }
},
"required": ["rumor_id"]
}),
handler: Box::new(|params| {
let rumor = params["rumor_id"].as_str().unwrap();
ToolResult::Success(json!({
"action": "share_rumor",
"rumor": rumor,
"unlocked": true
}))
}),
},
AiTool {
name: "offer_quest".into(),
description: "Offer a quest to the player".into(),
parameters: json!({
"type": "object",
"properties": {
"quest_id": { "type": "string" },
"urgency": { "type": "string", "enum": ["low", "medium", "high"] }
},
"required": ["quest_id"]
}),
handler: Box::new(|params| {
let quest = params["quest_id"].as_str().unwrap();
ToolResult::Success(json!({
"action": "offer_quest",
"quest": quest
}))
}),
},
AiTool {
name: "express_emotion".into(),
description: "Show an emotional reaction through animation".into(),
parameters: json!({
"type": "object",
"properties": {
"emotion": {
"type": "string",
"enum": ["happy", "sad", "angry", "surprised", "fearful", "disgusted"]
},
"intensity": { "type": "number", "minimum": 0, "maximum": 1 }
},
"required": ["emotion"]
}),
handler: Box::new(|params| {
let emotion = params["emotion"].as_str().unwrap();
let intensity = params.get("intensity")
.and_then(|v| v.as_f64())
.unwrap_or(0.5);
ToolResult::Success(json!({
"action": "express_emotion",
"emotion": emotion,
"intensity": intensity
}))
}),
},
]
}
}
Track player-NPC relationships:
#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct RelationshipTracker {
pub relationships: HashMap<Entity, Relationship>,
}
#[derive(Debug, Clone)]
pub struct Relationship {
pub affinity: i32,
pub trust: i32,
pub fear: i32,
pub respect: i32,
pub romantic_interest: i32,
pub interaction_count: u32,
pub last_interaction: f64,
pub memories: Vec<RelationshipMemory>,
}
#[derive(Debug, Clone)]
pub struct RelationshipMemory {
pub event: String,
pub impact: i32,
pub timestamp: f64,
}
impl Relationship {
pub fn new() -> Self {
Self {
affinity: 0,
trust: 0,
fear: 0,
respect: 0,
romantic_interest: 0,
interaction_count: 0,
last_interaction: 0.0,
memories: Vec::new(),
}
}
pub fn get_disposition(&self) -> Disposition {
let total = self.affinity + self.trust + self.respect - self.fear;
match total {
t if t >= 50 => Disposition::Friendly,
t if t >= 20 => Disposition::Warm,
t if t >= -20 => Disposition::Neutral,
t if t >= -50 => Disposition::Cool,
_ => Disposition::Hostile,
}
}
pub fn modify(&mut self, event: &str, changes: RelationshipChanges, time: f64) {
self.affinity = (self.affinity + changes.affinity).clamp(-100, 100);
self.trust = (self.trust + changes.trust).clamp(-100, 100);
self.fear = (self.fear + changes.fear).clamp(0, 100);
self.respect = (self.respect + changes.respect).clamp(-100, 100);
self.romantic_interest = (self.romantic_interest + changes.romantic).clamp(0, 100);
self.interaction_count += 1;
self.last_interaction = time;
let impact = changes.affinity + changes.trust + changes.respect;
if impact.abs() >= 5 {
self.memories.push(RelationshipMemory {
event: event.to_string(),
impact,
timestamp: time,
});
if self.memories.len() > 20 {
self.memories.sort_by(|a, b| b.impact.abs().cmp(&a.impact.abs()));
self.memories.truncate(10);
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Disposition {
Hostile,
Cool,
Neutral,
Warm,
Friendly,
}
#[derive(Debug, Clone, Default)]
pub struct RelationshipChanges {
pub affinity: i32,
pub trust: i32,
pub fear: i32,
pub respect: i32,
pub romantic: i32,
}
}
Modify dialogue based on relationship:
#![allow(unused)]
fn main() {
pub fn relationship_dialogue_modifier(
dialogue: &mut DynamicDialogue,
relationship: &Relationship,
) {
let disposition = relationship.get_disposition();
let style_modifier = match disposition {
Disposition::Hostile => "Be cold and dismissive. Keep responses short. Show distrust.",
Disposition::Cool => "Be polite but distant. Don't volunteer extra information.",
Disposition::Neutral => "Be professional and helpful when asked.",
Disposition::Warm => "Be friendly and open. Share more freely.",
Disposition::Friendly => "Be warm and trusting. Treat them as a close friend.",
};
dialogue.npc_persona.speaking_style.push_str(&format!("\n{}", style_modifier));
for memory in &relationship.memories {
dialogue.memory_context.push(format!(
"{} (impact: {:+})",
memory.event,
memory.impact
));
}
}
}
Generate voice output for dialogue:
#![allow(unused)]
fn main() {
use astraweave_audio::prelude::*;
#[derive(Component)]
pub struct VoiceDialogue {
pub voice_id: String,
pub pitch: f32,
pub speed: f32,
pub volume: f32,
}
pub struct VoiceSynthesizer {
tts_endpoint: String,
}
impl VoiceSynthesizer {
pub async fn synthesize(
&self,
text: &str,
voice: &VoiceDialogue,
) -> Result<AudioBuffer, VoiceError> {
let request = VoiceSynthRequest {
text: text.to_string(),
voice_id: voice.voice_id.clone(),
pitch: voice.pitch,
speed: voice.speed,
};
let response = reqwest::Client::new()
.post(&self.tts_endpoint)
.json(&request)
.send()
.await?
.bytes()
.await?;
Ok(AudioBuffer::from_bytes(&response))
}
}
pub fn dialogue_voice_system(
dialogue_query: Query<(&DialogueController, &VoiceDialogue)>,
synthesizer: Res<VoiceSynthesizer>,
mut audio_events: EventWriter<PlayAudioEvent>,
runtime: Res<TokioRuntime>,
) {
for (controller, voice) in dialogue_query.iter() {
if let Some(node) = controller.get_current_node() {
if let DialogueContent::Voice { text, audio_id } = &node.content {
if let Some(cached) = try_get_cached_audio(audio_id) {
audio_events.send(PlayAudioEvent {
buffer: cached,
volume: voice.volume,
spatial: false,
});
} else {
let text = text.clone();
let voice = voice.clone();
runtime.spawn(async move {
if let Ok(buffer) = synthesizer.synthesize(&text, &voice).await {
cache_audio(audio_id, buffer);
}
});
}
}
}
}
}
}
#![allow(unused)]
fn main() {
use astraweave_ui::prelude::*;
#[derive(Component)]
pub struct DialogueUI {
pub visible: bool,
pub speaker_name: String,
pub current_text: String,
pub responses: Vec<String>,
pub selected_response: usize,
pub text_progress: f32,
pub chars_per_second: f32,
}
impl DialogueUI {
pub fn show(&mut self, speaker: &str, text: &str, responses: Vec<String>) {
self.visible = true;
self.speaker_name = speaker.to_string();
self.current_text = text.to_string();
self.responses = responses;
self.selected_response = 0;
self.text_progress = 0.0;
}
pub fn get_visible_text(&self) -> &str {
let char_count = (self.current_text.len() as f32 * self.text_progress) as usize;
&self.current_text[..char_count.min(self.current_text.len())]
}
pub fn is_text_complete(&self) -> bool {
self.text_progress >= 1.0
}
pub fn skip_to_end(&mut self) {
self.text_progress = 1.0;
}
}
pub fn dialogue_ui_system(
mut ui_query: Query<&mut DialogueUI>,
dialogue_query: Query<&DialogueController>,
input: Res<Input>,
time: Res<Time>,
npc_query: Query<&NpcInfo>,
) {
for (mut ui, controller) in ui_query.iter_mut().zip(dialogue_query.iter()) {
if let Some(node) = controller.get_current_node() {
let speaker_name = npc_query
.get(node.speaker.0)
.map(|n| n.name.clone())
.unwrap_or_else(|_| "Unknown".to_string());
let text = match &node.content {
DialogueContent::Text(t) => t.clone(),
DialogueContent::Localized { fallback, .. } => fallback.clone(),
_ => String::new(),
};
let responses: Vec<String> = node.responses
.iter()
.map(|r| r.text.clone())
.collect();
if !ui.visible || ui.current_text != text {
ui.show(&speaker_name, &text, responses);
}
if !ui.is_text_complete() {
ui.text_progress += time.delta_seconds() * ui.chars_per_second
/ ui.current_text.len() as f32;
ui.text_progress = ui.text_progress.min(1.0);
}
if input.just_pressed(KeyCode::Space) || input.just_pressed(MouseButton::Left) {
if !ui.is_text_complete() {
ui.skip_to_end();
}
}
if ui.is_text_complete() {
if input.just_pressed(KeyCode::Up) {
ui.selected_response = ui.selected_response.saturating_sub(1);
}
if input.just_pressed(KeyCode::Down) {
ui.selected_response = (ui.selected_response + 1).min(ui.responses.len() - 1);
}
}
} else {
ui.visible = false;
}
}
}
}
1. **Blend Scripted and Dynamic**: Use scripted dialogue for critical story beats, dynamic for flavor
2. **Maintain Consistency**: Ensure LLM responses align with established NPC personality
3. **Respect Player Time**: Keep responses concise; players can ask for details
4. **Branch Meaningfully**: Only offer choices that have real consequences
- **Context Window Overflow**: Trim conversation history to prevent LLM context issues
- **Hallucination**: Validate LLM outputs against game state to prevent impossible claims
- **Tone Whiplash**: Ensure relationship state affects dialogue tone gradually
- **Dead Ends**: Always provide a way to exit or continue dialogue
AstraWeave’s PCG system combines traditional algorithmic generation with AI-driven content creation, enabling infinite variety while maintaining coherent, designer-guided output.
graph TB
subgraph Input["Generation Input"]
SEED[Seed/Parameters] --> GEN
RULES[Design Rules] --> GEN
CONTEXT[World Context] --> GEN
end
subgraph Generator["PCG Pipeline"]
GEN[Generator Core] --> ALGO[Algorithmic Pass]
ALGO --> AI[AI Enhancement]
AI --> VAL[Validation]
VAL --> POST[Post-Processing]
end
subgraph Output["Generated Content"]
POST --> TERRAIN[Terrain]
POST --> DUNGEONS[Dungeons]
POST --> ITEMS[Items]
POST --> QUESTS[Quests]
POST --> NPCS[NPCs]
end
VAL -->|Reject| GEN
Define the common interface for all generators:
#![allow(unused)]
fn main() {
use astraweave_ecs::prelude::*;
use std::hash::{Hash, Hasher};
pub trait Generator<T> {
fn generate(&self, params: &GenerationParams) -> GenerationResult<T>;
fn validate(&self, output: &T, params: &GenerationParams) -> ValidationResult;
fn seed_from_hash<H: Hash>(&self, hashable: H) -> u64;
}
#[derive(Debug, Clone)]
pub struct GenerationParams {
pub seed: u64,
pub difficulty: f32,
pub density: f32,
pub theme: String,
pub constraints: Vec<GenerationConstraint>,
pub context: GenerationContext,
}
#[derive(Debug, Clone)]
pub struct GenerationContext {
pub world_position: Vec3,
pub biome: String,
pub player_level: u32,
pub story_stage: String,
pub nearby_content: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum GenerationConstraint {
MinSize(f32),
MaxSize(f32),
MustContain(String),
MustNotContain(String),
ConnectTo(Vec3),
StyleMatch(String),
Custom { name: String, value: String },
}
pub type GenerationResult<T> = Result<T, GenerationError>;
#[derive(Debug)]
pub enum GenerationError {
InvalidParams(String),
ConstraintViolation(String),
MaxIterationsReached,
ValidationFailed(String),
}
#[derive(Debug)]
pub enum ValidationResult {
Valid,
Invalid(String),
NeedsAdjustment(Vec<String>),
}
}
Deterministic random number generation:
#![allow(unused)]
fn main() {
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
pub struct SeededRng {
rng: ChaCha8Rng,
initial_seed: u64,
}
impl SeededRng {
pub fn new(seed: u64) -> Self {
Self {
rng: ChaCha8Rng::seed_from_u64(seed),
initial_seed: seed,
}
}
pub fn from_position(x: i32, y: i32, z: i32, world_seed: u64) -> Self {
let position_hash = ((x as u64) << 42) ^ ((y as u64) << 21) ^ (z as u64);
Self::new(world_seed ^ position_hash)
}
pub fn next_float(&mut self) -> f32 {
self.rng.gen()
}
pub fn next_range(&mut self, min: i32, max: i32) -> i32 {
self.rng.gen_range(min..=max)
}
pub fn next_float_range(&mut self, min: f32, max: f32) -> f32 {
self.rng.gen_range(min..=max)
}
pub fn choose<T: Clone>(&mut self, options: &[T]) -> Option<T> {
if options.is_empty() {
None
} else {
let index = self.rng.gen_range(0..options.len());
Some(options[index].clone())
}
}
pub fn weighted_choose<T: Clone>(&mut self, options: &[(T, f32)]) -> Option<T> {
let total_weight: f32 = options.iter().map(|(_, w)| w).sum();
let mut roll = self.next_float() * total_weight;
for (item, weight) in options {
roll -= weight;
if roll <= 0.0 {
return Some(item.clone());
}
}
options.last().map(|(item, _)| item.clone())
}
pub fn fork(&mut self) -> Self {
Self::new(self.rng.gen())
}
}
}
Generate terrain heightmaps with noise:
#![allow(unused)]
fn main() {
use noise::{NoiseFn, Perlin, Fbm, MultiFractal};
pub struct TerrainGenerator {
base_noise: Fbm<Perlin>,
detail_noise: Perlin,
erosion_passes: u32,
}
impl TerrainGenerator {
pub fn new(seed: u64) -> Self {
let mut fbm = Fbm::new(seed as u32);
fbm.octaves = 6;
fbm.frequency = 0.005;
fbm.lacunarity = 2.0;
fbm.persistence = 0.5;
Self {
base_noise: fbm,
detail_noise: Perlin::new(seed as u32 + 1),
erosion_passes: 50,
}
}
pub fn generate_chunk(&self, chunk_x: i32, chunk_z: i32, size: usize) -> Heightmap {
let mut heights = vec![vec![0.0f32; size]; size];
for z in 0..size {
for x in 0..size {
let world_x = (chunk_x * size as i32 + x as i32) as f64;
let world_z = (chunk_z * size as i32 + z as i32) as f64;
let base = self.base_noise.get([world_x, world_z]) as f32;
let detail = self.detail_noise.get([world_x * 0.1, world_z * 0.1]) as f32 * 0.1;
heights[z][x] = (base + detail + 1.0) * 0.5 * 256.0;
}
}
self.apply_erosion(&mut heights);
Heightmap {
data: heights,
chunk_x,
chunk_z,
size,
}
}
fn apply_erosion(&self, heights: &mut Vec<Vec<f32>>) {
let size = heights.len();
let mut water = vec![vec![0.0f32; size]; size];
let mut sediment = vec![vec![0.0f32; size]; size];
for _ in 0..self.erosion_passes {
for z in 1..size - 1 {
for x in 1..size - 1 {
let current = heights[z][x];
let neighbors = [
(heights[z - 1][x], 0, -1),
(heights[z + 1][x], 0, 1),
(heights[z][x - 1], -1, 0),
(heights[z][x + 1], 1, 0),
];
if let Some((lowest, dx, dz)) = neighbors
.iter()
.filter(|(h, _, _)| *h < current)
.min_by(|a, b| a.0.partial_cmp(&b.0).unwrap())
{
let diff = current - lowest;
let transfer = diff * 0.1;
heights[z][x] -= transfer * 0.5;
heights[(z as i32 + dz) as usize][(x as i32 + dx) as usize] += transfer * 0.3;
}
}
}
}
}
}
#[derive(Debug)]
pub struct Heightmap {
pub data: Vec<Vec<f32>>,
pub chunk_x: i32,
pub chunk_z: i32,
pub size: usize,
}
impl Heightmap {
pub fn get_height(&self, x: usize, z: usize) -> f32 {
self.data.get(z).and_then(|row| row.get(x)).copied().unwrap_or(0.0)
}
pub fn get_normal(&self, x: usize, z: usize) -> Vec3 {
let h_l = self.get_height(x.saturating_sub(1), z);
let h_r = self.get_height((x + 1).min(self.size - 1), z);
let h_d = self.get_height(x, z.saturating_sub(1));
let h_u = self.get_height(x, (z + 1).min(self.size - 1));
Vec3::new(h_l - h_r, 2.0, h_d - h_u).normalize()
}
}
}
Assign biomes based on terrain properties:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Biome {
Ocean,
Beach,
Plains,
Forest,
Desert,
Tundra,
Mountains,
Swamp,
Jungle,
Volcanic,
}
pub struct BiomeGenerator {
temperature_noise: Perlin,
moisture_noise: Perlin,
}
impl BiomeGenerator {
pub fn new(seed: u64) -> Self {
Self {
temperature_noise: Perlin::new(seed as u32 + 100),
moisture_noise: Perlin::new(seed as u32 + 200),
}
}
pub fn get_biome(&self, x: f64, z: f64, height: f32) -> Biome {
if height < 10.0 {
return Biome::Ocean;
}
if height < 15.0 {
return Biome::Beach;
}
if height > 200.0 {
return Biome::Mountains;
}
let temp = (self.temperature_noise.get([x * 0.001, z * 0.001]) + 1.0) * 0.5;
let moisture = (self.moisture_noise.get([x * 0.001, z * 0.001]) + 1.0) * 0.5;
let temp = temp as f32 - (height - 50.0) * 0.002;
match (temp, moisture as f32) {
(t, _) if t < 0.2 => Biome::Tundra,
(t, m) if t > 0.8 && m < 0.3 => Biome::Desert,
(t, m) if t > 0.7 && m > 0.7 => Biome::Jungle,
(_, m) if m > 0.8 => Biome::Swamp,
(_, m) if m > 0.5 => Biome::Forest,
_ => Biome::Plains,
}
}
pub fn get_biome_properties(&self, biome: Biome) -> BiomeProperties {
match biome {
Biome::Ocean => BiomeProperties {
tree_density: 0.0,
grass_density: 0.0,
rock_density: 0.1,
enemy_level_mod: 0.8,
ambient_sound: "ocean".into(),
},
Biome::Forest => BiomeProperties {
tree_density: 0.7,
grass_density: 0.8,
rock_density: 0.2,
enemy_level_mod: 1.0,
ambient_sound: "forest".into(),
},
Biome::Desert => BiomeProperties {
tree_density: 0.02,
grass_density: 0.05,
rock_density: 0.4,
enemy_level_mod: 1.2,
ambient_sound: "desert".into(),
},
Biome::Mountains => BiomeProperties {
tree_density: 0.1,
grass_density: 0.2,
rock_density: 0.8,
enemy_level_mod: 1.5,
ambient_sound: "wind".into(),
},
_ => BiomeProperties::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct BiomeProperties {
pub tree_density: f32,
pub grass_density: f32,
pub rock_density: f32,
pub enemy_level_mod: f32,
pub ambient_sound: String,
}
}
Generate dungeons using room placement:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub struct DungeonLayout {
pub rooms: Vec<Room>,
pub corridors: Vec<Corridor>,
pub entry_room: usize,
pub boss_room: usize,
pub width: i32,
pub height: i32,
}
#[derive(Debug, Clone)]
pub struct Room {
pub id: usize,
pub bounds: Rect,
pub room_type: RoomType,
pub connections: Vec<usize>,
pub content: RoomContent,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum RoomType {
Entry,
Normal,
Treasure,
Boss,
Secret,
Shop,
Shrine,
}
#[derive(Debug, Clone)]
pub struct RoomContent {
pub enemies: Vec<EnemySpawn>,
pub items: Vec<ItemSpawn>,
pub props: Vec<PropSpawn>,
pub triggers: Vec<TriggerSpawn>,
}
#[derive(Debug, Clone)]
pub struct Corridor {
pub from_room: usize,
pub to_room: usize,
pub path: Vec<(i32, i32)>,
pub width: i32,
}
pub struct DungeonGenerator {
min_rooms: usize,
max_rooms: usize,
room_size_range: (i32, i32),
room_templates: Vec<RoomTemplate>,
}
impl DungeonGenerator {
pub fn generate(&self, params: &GenerationParams) -> GenerationResult<DungeonLayout> {
let mut rng = SeededRng::new(params.seed);
let room_count = rng.next_range(self.min_rooms as i32, self.max_rooms as i32) as usize;
let mut rooms = Vec::new();
let mut attempts = 0;
let max_attempts = room_count * 100;
while rooms.len() < room_count && attempts < max_attempts {
let width = rng.next_range(self.room_size_range.0, self.room_size_range.1);
let height = rng.next_range(self.room_size_range.0, self.room_size_range.1);
let x = rng.next_range(0, 100 - width);
let y = rng.next_range(0, 100 - height);
let bounds = Rect { x, y, width, height };
if !rooms.iter().any(|r: &Room| r.bounds.intersects_padded(&bounds, 2)) {
let room_type = if rooms.is_empty() {
RoomType::Entry
} else {
self.choose_room_type(&mut rng, rooms.len(), room_count)
};
rooms.push(Room {
id: rooms.len(),
bounds,
room_type,
connections: Vec::new(),
content: RoomContent::default(),
});
}
attempts += 1;
}
if rooms.len() < self.min_rooms {
return Err(GenerationError::MaxIterationsReached);
}
let corridors = self.connect_rooms(&mut rooms, &mut rng);
let boss_room = rooms.iter()
.enumerate()
.filter(|(_, r)| r.room_type != RoomType::Entry)
.max_by_key(|(_, r)| self.distance_from_entry(&rooms, r.id))
.map(|(i, _)| i)
.unwrap_or(rooms.len() - 1);
rooms[boss_room].room_type = RoomType::Boss;
for room in &mut rooms {
room.content = self.generate_room_content(room, params, &mut rng);
}
Ok(DungeonLayout {
rooms,
corridors,
entry_room: 0,
boss_room,
width: 100,
height: 100,
})
}
fn connect_rooms(&self, rooms: &mut [Room], rng: &mut SeededRng) -> Vec<Corridor> {
let mut corridors = Vec::new();
let mut connected = vec![false; rooms.len()];
connected[0] = true;
while connected.iter().any(|&c| !c) {
let mut best_pair = None;
let mut best_dist = f32::MAX;
for (i, room_a) in rooms.iter().enumerate() {
if !connected[i] {
continue;
}
for (j, room_b) in rooms.iter().enumerate() {
if connected[j] {
continue;
}
let dist = room_a.bounds.center_distance(&room_b.bounds);
if dist < best_dist {
best_dist = dist;
best_pair = Some((i, j));
}
}
}
if let Some((from, to)) = best_pair {
let path = self.create_corridor_path(
rooms[from].bounds.center(),
rooms[to].bounds.center(),
rng,
);
rooms[from].connections.push(to);
rooms[to].connections.push(from);
connected[to] = true;
corridors.push(Corridor {
from_room: from,
to_room: to,
path,
width: 2,
});
} else {
break;
}
}
corridors
}
fn create_corridor_path(
&self,
start: (i32, i32),
end: (i32, i32),
rng: &mut SeededRng,
) -> Vec<(i32, i32)> {
let mut path = Vec::new();
let (mut x, mut y) = start;
if rng.next_float() > 0.5 {
while x != end.0 {
path.push((x, y));
x += (end.0 - x).signum();
}
while y != end.1 {
path.push((x, y));
y += (end.1 - y).signum();
}
} else {
while y != end.1 {
path.push((x, y));
y += (end.1 - y).signum();
}
while x != end.0 {
path.push((x, y));
x += (end.0 - x).signum();
}
}
path.push(end);
path
}
fn choose_room_type(&self, rng: &mut SeededRng, current: usize, total: usize) -> RoomType {
let options = [
(RoomType::Normal, 10.0),
(RoomType::Treasure, 2.0),
(RoomType::Secret, 1.0),
(RoomType::Shop, 1.5),
(RoomType::Shrine, 1.0),
];
rng.weighted_choose(&options).unwrap_or(RoomType::Normal)
}
fn generate_room_content(
&self,
room: &Room,
params: &GenerationParams,
rng: &mut SeededRng,
) -> RoomContent {
let mut content = RoomContent::default();
match room.room_type {
RoomType::Entry => {}
RoomType::Normal => {
let enemy_count = rng.next_range(1, 4);
for _ in 0..enemy_count {
content.enemies.push(EnemySpawn {
position: room.bounds.random_point(rng),
enemy_type: "skeleton".into(),
level: params.context.player_level,
});
}
}
RoomType::Treasure => {
content.items.push(ItemSpawn {
position: room.bounds.center(),
loot_table: "treasure_chest".into(),
});
}
RoomType::Boss => {
content.enemies.push(EnemySpawn {
position: room.bounds.center(),
enemy_type: "boss".into(),
level: params.context.player_level + 5,
});
}
_ => {}
}
content
}
}
}
Generate items with random properties:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub struct GeneratedItem {
pub base_type: ItemBaseType,
pub name: String,
pub rarity: ItemRarity,
pub level: u32,
pub stats: ItemStats,
pub affixes: Vec<ItemAffix>,
pub special_ability: Option<SpecialAbility>,
}
#[derive(Debug, Clone)]
pub struct ItemStats {
pub damage: Option<(f32, f32)>,
pub armor: Option<f32>,
pub durability: f32,
pub weight: f32,
}
#[derive(Debug, Clone)]
pub struct ItemAffix {
pub affix_type: AffixType,
pub tier: u32,
pub value: f32,
}
#[derive(Debug, Clone, Copy)]
pub enum AffixType {
Strength,
Agility,
Intelligence,
Vitality,
FireDamage,
IceDamage,
LightningDamage,
LifeSteal,
CriticalChance,
CriticalDamage,
AttackSpeed,
MovementSpeed,
}
pub struct ItemGenerator {
name_generator: NameGenerator,
affix_pool: Vec<AffixTemplate>,
}
impl ItemGenerator {
pub fn generate(&self, params: &ItemGenParams, rng: &mut SeededRng) -> GeneratedItem {
let rarity = self.roll_rarity(params.base_rarity_chance, rng);
let affix_count = self.affix_count_for_rarity(rarity);
let base = ¶ms.base_type;
let mut stats = self.roll_base_stats(base, params.level, rng);
let mut affixes = Vec::new();
for _ in 0..affix_count {
if let Some(affix) = self.roll_affix(params.level, &affixes, rng) {
affixes.push(affix);
}
}
for affix in &affixes {
self.apply_affix_to_stats(&mut stats, affix);
}
let special = if rarity >= ItemRarity::Epic && rng.next_float() > 0.5 {
Some(self.generate_special_ability(params.level, rng))
} else {
None
};
let name = self.generate_name(base, &affixes, rarity, rng);
GeneratedItem {
base_type: base.clone(),
name,
rarity,
level: params.level,
stats,
affixes,
special_ability: special,
}
}
fn roll_rarity(&self, base_chance: f32, rng: &mut SeededRng) -> ItemRarity {
let roll = rng.next_float() * base_chance;
match roll {
r if r > 0.99 => ItemRarity::Legendary,
r if r > 0.95 => ItemRarity::Epic,
r if r > 0.85 => ItemRarity::Rare,
r if r > 0.65 => ItemRarity::Uncommon,
_ => ItemRarity::Common,
}
}
fn affix_count_for_rarity(&self, rarity: ItemRarity) -> usize {
match rarity {
ItemRarity::Common => 0,
ItemRarity::Uncommon => 1,
ItemRarity::Rare => 2,
ItemRarity::Epic => 3,
ItemRarity::Legendary => 4,
}
}
fn roll_affix(
&self,
level: u32,
existing: &[ItemAffix],
rng: &mut SeededRng,
) -> Option<ItemAffix> {
let available: Vec<_> = self.affix_pool
.iter()
.filter(|a| a.min_level <= level)
.filter(|a| !existing.iter().any(|e| e.affix_type == a.affix_type))
.collect();
if available.is_empty() {
return None;
}
let template = rng.choose(&available)?;
let tier = rng.next_range(1, template.max_tier as i32) as u32;
let value = template.base_value * (1.0 + tier as f32 * 0.25);
Some(ItemAffix {
affix_type: template.affix_type,
tier,
value,
})
}
fn generate_name(
&self,
base: &ItemBaseType,
affixes: &[ItemAffix],
rarity: ItemRarity,
rng: &mut SeededRng,
) -> String {
if rarity >= ItemRarity::Legendary {
return self.name_generator.generate_legendary_name(base, rng);
}
let prefix = affixes.first().map(|a| self.affix_to_prefix(a));
let suffix = affixes.get(1).map(|a| self.affix_to_suffix(a));
match (prefix, suffix) {
(Some(p), Some(s)) => format!("{} {} {}", p, base.display_name(), s),
(Some(p), None) => format!("{} {}", p, base.display_name()),
(None, Some(s)) => format!("{} {}", base.display_name(), s),
(None, None) => base.display_name().to_string(),
}
}
}
}
Use AI for narrative content:
#![allow(unused)]
fn main() {
use astraweave_llm::prelude::*;
pub struct AiContentGenerator {
llm: LlmClient,
templates: ContentTemplates,
}
impl AiContentGenerator {
pub async fn generate_quest(
&self,
context: &QuestGenContext,
) -> Result<GeneratedQuest, GenerationError> {
let prompt = format!(
r#"Generate a side quest for a fantasy RPG.
Setting: {}
Player level: {}
Available NPCs: {}
Recent events: {}
Theme: {}
Generate a quest with:
1. Title (catchy, thematic)
2. Description (2-3 sentences)
3. Objectives (3-5 steps)
4. Rewards (appropriate for level)
5. Optional twist or complication
Return as JSON."#,
context.location,
context.player_level,
context.available_npcs.join(", "),
context.recent_events.join(", "),
context.theme
);
let response = self.llm.complete(&prompt).await
.map_err(|e| GenerationError::InvalidParams(e.to_string()))?;
self.parse_quest_response(&response)
}
pub async fn generate_npc_backstory(
&self,
npc: &NpcGenContext,
) -> Result<NpcBackstory, GenerationError> {
let prompt = format!(
r#"Create a backstory for an NPC in a fantasy game.
Name: {}
Role: {}
Location: {}
Personality traits: {}
Generate:
1. Background (2-3 sentences)
2. Current motivation
3. Secret or hidden aspect
4. Connection to the world
5. Speech mannerism
Keep responses concise and game-appropriate."#,
npc.name,
npc.role,
npc.location,
npc.personality_traits.join(", ")
);
let response = self.llm.complete(&prompt).await
.map_err(|e| GenerationError::InvalidParams(e.to_string()))?;
self.parse_npc_response(&response)
}
pub async fn generate_location_description(
&self,
location: &LocationGenContext,
) -> Result<LocationDescription, GenerationError> {
let prompt = format!(
r#"Describe a location for a fantasy game.
Type: {}
Biome: {}
Key features: {}
Mood: {}
Provide:
1. Name (evocative)
2. Short description (1 sentence for UI)
3. Full description (2-3 sentences for exploration)
4. Notable elements (3-5 items)
5. Ambient sounds suggestion"#,
location.location_type,
location.biome,
location.features.join(", "),
location.mood
);
let response = self.llm.complete(&prompt).await
.map_err(|e| GenerationError::InvalidParams(e.to_string()))?;
self.parse_location_response(&response)
}
}
#[derive(Debug)]
pub struct GeneratedQuest {
pub title: String,
pub description: String,
pub objectives: Vec<QuestObjective>,
pub rewards: Vec<QuestReward>,
pub twist: Option<String>,
}
#[derive(Debug)]
pub struct NpcBackstory {
pub background: String,
pub motivation: String,
pub secret: String,
pub world_connection: String,
pub speech_pattern: String,
}
}
Ensure generated content meets requirements:
#![allow(unused)]
fn main() {
pub struct ContentValidator {
rules: Vec<ValidationRule>,
}
pub enum ValidationRule {
MinRooms(usize),
MaxDeadEnds(usize),
RequireRoomType(RoomType),
MaxDifficulty(f32),
Connectivity,
NoOverlap,
Custom(Box<dyn Fn(&DungeonLayout) -> ValidationResult>),
}
impl ContentValidator {
pub fn validate_dungeon(&self, dungeon: &DungeonLayout) -> ValidationResult {
for rule in &self.rules {
match rule {
ValidationRule::MinRooms(min) => {
if dungeon.rooms.len() < *min {
return ValidationResult::Invalid(
format!("Too few rooms: {} < {}", dungeon.rooms.len(), min)
);
}
}
ValidationRule::MaxDeadEnds(max) => {
let dead_ends = dungeon.rooms
.iter()
.filter(|r| r.connections.len() <= 1)
.count();
if dead_ends > *max {
return ValidationResult::Invalid(
format!("Too many dead ends: {} > {}", dead_ends, max)
);
}
}
ValidationRule::RequireRoomType(room_type) => {
if !dungeon.rooms.iter().any(|r| r.room_type == *room_type) {
return ValidationResult::Invalid(
format!("Missing required room type: {:?}", room_type)
);
}
}
ValidationRule::Connectivity => {
if !self.check_connectivity(dungeon) {
return ValidationResult::Invalid("Dungeon is not fully connected".into());
}
}
_ => {}
}
}
ValidationResult::Valid
}
fn check_connectivity(&self, dungeon: &DungeonLayout) -> bool {
if dungeon.rooms.is_empty() {
return true;
}
let mut visited = vec![false; dungeon.rooms.len()];
let mut stack = vec![0usize];
while let Some(current) = stack.pop() {
if visited[current] {
continue;
}
visited[current] = true;
for &connected in &dungeon.rooms[current].connections {
if !visited[connected] {
stack.push(connected);
}
}
}
visited.iter().all(|&v| v)
}
}
}
1. **Seed Everything**: Use deterministic RNG for reproducible generation
2. **Validate Early**: Reject invalid content before expensive operations
3. **Layer Generation**: Start coarse, refine with detail passes
4. **Cache Results**: Store expensive generation results for reuse
- **Infinite Loops**: Always limit generation attempts
- **Memory Bloat**: Stream large content; don't hold everything in memory
- **Sameness**: Vary parameters enough to feel unique
- **Performance**: Profile generation; move to background threads if slow
Crates : astraweave-persistence-ecs, astraweave-coreStatus : Production Ready
AstraWeave provides deterministic save/load with full ECS state serialization, player profiles, and versioned migrations.
#![allow(unused)]
fn main() {
use astraweave_persistence_ecs::{SaveSystem, LoadSystem, SaveConfig};
use astraweave_ecs::World;
let config = SaveConfig {
path: "saves/",
compression: true,
encryption: false,
..Default::default()
};
let save_system = SaveSystem::new(config);
// Save world state
save_system.save(&world, "quicksave")?;
// Load world state
let loaded_world = save_system.load("quicksave")?;
}
Save File Structure
├── Header (version, timestamp, checksum)
├── World State
│ ├── Entities (sparse set serialization)
│ ├── Components (per-archetype)
│ └── Resources (global state)
├── Player Data
│ ├── Inventory
│ ├── Progress
│ └── Settings
└── Metadata
├── Screenshot (optional)
└── Play time
Components must be registered for serialization:
#![allow(unused)]
fn main() {
use astraweave_ecs::{Component, Serialize, Deserialize};
#[derive(Component, Serialize, Deserialize)]
struct Health {
current: f32,
max: f32,
}
#[derive(Component, Serialize, Deserialize)]
struct Position {
x: f32,
y: f32,
z: f32,
}
// Register with world
app.register_serializable::<Health>();
app.register_serializable::<Position>();
}
Mark components to skip during save:
#![allow(unused)]
fn main() {
#[derive(Component)]
#[component(skip_save)]
struct RenderCache {
// Regenerated at load time
}
#[derive(Component, Serialize, Deserialize)]
#[component(save_priority = "high")]
struct PlayerData {
// Saved first, loaded first
}
}
#![allow(unused)]
fn main() {
use astraweave_persistence_ecs::{SaveSlot, SlotManager};
let mut slots = SlotManager::new("saves/", 10); // 10 slots
// Get available slots
let available = slots.list()?;
for slot in &available {
println!("Slot {}: {} ({})", slot.index, slot.name, slot.timestamp);
}
// Save to slot
slots.save_to_slot(3, &world, "My Save")?;
// Load from slot
let world = slots.load_from_slot(3)?;
// Delete slot
slots.delete_slot(3)?;
}
#![allow(unused)]
fn main() {
use astraweave_persistence_ecs::AutoSave;
let mut autosave = AutoSave::new(Duration::from_secs(300)); // 5 minutes
// In game loop
if autosave.should_save() {
slots.save_to_slot(0, &world, "Autosave")?;
autosave.mark_saved();
}
}
#![allow(unused)]
fn main() {
use astraweave_persistence_ecs::{PlayerProfile, ProfileManager};
let mut profiles = ProfileManager::new("profiles/")?;
// Create profile
let profile = PlayerProfile::new("Player1");
profiles.create(profile)?;
// Load profile
let profile = profiles.load("Player1")?;
// Update settings
profile.settings.volume = 0.8;
profile.settings.difficulty = Difficulty::Hard;
profiles.save(&profile)?;
}
#![allow(unused)]
fn main() {
pub struct PlayerProfile {
pub name: String,
pub created: DateTime,
pub play_time: Duration,
pub settings: PlayerSettings,
pub achievements: Vec<Achievement>,
pub statistics: PlayerStats,
}
pub struct PlayerSettings {
pub volume: f32,
pub music_volume: f32,
pub sfx_volume: f32,
pub voice_volume: f32,
pub difficulty: Difficulty,
pub controls: ControlBindings,
pub graphics: GraphicsSettings,
}
}
#![allow(unused)]
fn main() {
use astraweave_persistence_ecs::{SaveVersion, Migration};
// Current save version
const SAVE_VERSION: SaveVersion = SaveVersion::new(1, 2, 0);
// Load with migration
let world = save_system.load_with_migrations(
"old_save",
&[
Migration::new(
SaveVersion::new(1, 0, 0),
SaveVersion::new(1, 1, 0),
migrate_1_0_to_1_1,
),
Migration::new(
SaveVersion::new(1, 1, 0),
SaveVersion::new(1, 2, 0),
migrate_1_1_to_1_2,
),
],
)?;
fn migrate_1_0_to_1_1(data: &mut SaveData) -> Result<()> {
// Add new health.shield field
for entity in data.entities_with::<Health>() {
let health: &mut Health = data.get_mut(entity)?;
health.shield = 0.0; // New field
}
Ok(())
}
}
#![allow(unused)]
fn main() {
use astraweave_persistence_ecs::BackupConfig;
let config = SaveConfig {
backup: BackupConfig {
enabled: true,
max_backups: 3,
backup_on_save: true,
},
..Default::default()
};
// Backups created automatically
// saves/quicksave.sav
// saves/quicksave.sav.bak1
// saves/quicksave.sav.bak2
// saves/quicksave.sav.bak3
}
#![allow(unused)]
fn main() {
use astraweave_persistence_ecs::verify_save;
match verify_save("saves/quicksave.sav") {
Ok(info) => {
println!("Valid save: v{}", info.version);
}
Err(SaveError::Corrupted) => {
// Try backup
if let Ok(backup) = find_valid_backup("quicksave") {
restore_from_backup(backup)?;
}
}
Err(SaveError::VersionMismatch(v)) => {
println!("Save from future version: {}", v);
}
}
}
AstraWeave’s deterministic ECS enables save/load that reproduces exact game states:
#![allow(unused)]
fn main() {
use astraweave_core::replay::{ReplayRecorder, ReplayPlayer};
// Record session
let mut recorder = ReplayRecorder::new();
recorder.start(&world);
// During gameplay
recorder.record_frame(&world, &inputs);
// Save replay
recorder.save("replay.bin")?;
// Play back (bit-identical to original)
let mut player = ReplayPlayer::load("replay.bin")?;
while let Some(frame) = player.next_frame() {
world.apply_inputs(frame.inputs);
world.step();
assert_eq!(world.checksum(), frame.checksum);
}
}
Operation Latency Notes
Quick save (1K entities) ~50 ms With compression
Quick load (1K entities) ~30 ms With decompression
Profile save ~5 ms Settings only
Checksum verify ~2 ms Per save file
Async saves - Don’t block gameplay
Incremental saves - Only save changed data
Compression - 60-80% size reduction
Memory-mapped loading - Faster large worlds
#![allow(unused)]
fn main() {
// Async save
let handle = save_system.save_async(&world, "quicksave");
// Continue gameplay...
// Wait for completion
handle.await?;
}
Crates : astraweave-scripting, astraweave-authorStatus : Production Ready (Sandboxed)
AstraWeave provides secure Rhai scripting for modding, behavior authoring, and runtime customization with full sandboxing.
// scripts/my_behavior.rhai
// Called when entity spawns
fn on_spawn(entity) {
log("Entity spawned: " + entity.id);
entity.set_health(100);
entity.play_animation("idle");
}
// Called every frame
fn on_update(entity, dt) {
let player = find_nearest("Player");
if distance(entity.position, player.position) < 10.0 {
entity.look_at(player);
entity.move_towards(player, dt * 5.0);
}
}
// Called when taking damage
fn on_damage(entity, amount, source) {
if entity.health - amount <= 0 {
entity.play_animation("death");
spawn_particles("blood", entity.position, 20);
}
}
#![allow(unused)]
fn main() {
use astraweave_scripting::{ScriptEngine, ScriptConfig};
let config = ScriptConfig {
max_operations: 100_000, // Operation limit per call
timeout_ms: 10, // Max execution time
memory_limit_kb: 1024, // Memory limit
allow_network: false, // Network access
allow_filesystem: false, // File access
};
let mut engine = ScriptEngine::new(config);
// Register scripts
engine.load_script("behaviors/enemy.rhai")?;
engine.load_script("behaviors/companion.rhai")?;
// Execute
engine.call("on_spawn", (entity,))?;
}
// Position & Movement
entity.position // Get Vec3
entity.set_position(x, y, z) // Set position
entity.move_towards(target, speed)
entity.look_at(target)
entity.rotate(yaw, pitch, roll)
// Health & Combat
entity.health // Current health
entity.max_health // Maximum health
entity.set_health(amount)
entity.damage(amount)
entity.heal(amount)
entity.is_dead()
// Animation
entity.play_animation(name)
entity.stop_animation()
entity.is_animating()
// Components
entity.has_component("Physics")
entity.get_tag("faction")
entity.set_tag("faction", "enemy")
// Entity queries
let player = find_nearest("Player");
let enemies = find_all("Enemy");
let nearby = find_in_radius(position, radius);
// Spawning
let entity = spawn("enemy_type", position);
destroy(entity);
// Raycasting
let hit = raycast(origin, direction, max_distance);
if hit.success {
log("Hit: " + hit.entity.name + " at " + hit.point);
}
// Time
let time = game_time();
let dt = delta_time();
// Vectors
let v = vec3(1.0, 2.0, 3.0);
let dist = distance(a, b);
let dir = normalize(v);
let dot = dot_product(a, b);
let cross = cross_product(a, b);
// Interpolation
let result = lerp(a, b, t);
let smooth = smoothstep(a, b, t);
// Random
let r = random(); // 0.0 - 1.0
let ri = random_range(1, 10); // 1 - 10
let choice = random_choice(array);
// Sound playback
play_sound("sfx/explosion.ogg");
play_sound_at("sfx/growl.ogg", position);
play_music("music/combat.ogg", fade_in_seconds);
stop_music(fade_out_seconds);
set_volume("sfx", 0.8);
spawn_particles("fire", position, count);
spawn_particles_at_entity("smoke", entity, count);
stop_particles(particle_system);
#![allow(unused)]
fn main() {
use astraweave_scripting::ScriptComponent;
commands.spawn((
Transform::default(),
Health::new(100),
ScriptComponent::new("behaviors/enemy.rhai"),
));
}
Callback When Called Parameters
on_spawnEntity created (entity)
on_updateEvery frame (entity, dt)
on_destroyEntity removed (entity)
on_collisionPhysics collision (entity, other, contact)
on_triggerTrigger overlap (entity, other, entered)
on_damageTaking damage (entity, amount, source)
on_deathHealth reaches 0 (entity, killer)
on_interactPlayer interaction (entity, player)
// This will be terminated after 100,000 operations
fn infinite_loop(entity) {
while true {
// Will hit operation limit
}
}
fn memory_hog(entity) {
let huge_array = [];
for i in 0..1000000 {
huge_array.push(i); // Will hit memory limit
}
}
Scripts cannot:
Access filesystem
Open network connections
Call system commands
Modify engine internals
Access other scripts’ data
Scripts can be modified at runtime:
#![allow(unused)]
fn main() {
// Enable hot reload
engine.enable_hot_reload(true);
// In game loop
engine.check_for_changes()?; // Reloads modified scripts
}
Development workflow :
Run game
Edit .rhai files
Changes apply immediately
No restart needed
#![allow(unused)]
fn main() {
use astraweave_scripting::ScriptEngine;
engine.register_fn("custom_ability", |entity: Entity, power: f64| {
// Rust implementation
let damage = power * 2.0;
apply_area_damage(entity.position(), damage, 5.0);
});
}
fn on_special_attack(entity) {
custom_ability(entity, 50.0);
play_sound("sfx/special.ogg");
spawn_particles("explosion", entity.position, 100);
}
// AI state machine
let state = "idle";
fn on_update(entity, dt) {
switch state {
"idle" => {
if see_player() {
state = "chase";
}
}
"chase" => {
let player = find_nearest("Player");
entity.move_towards(player, dt * 8.0);
if distance(entity.position, player.position) < 2.0 {
state = "attack";
}
}
"attack" => {
entity.attack();
state = "cooldown";
set_timer("attack_cooldown", 1.0);
}
"cooldown" => {
if timer_expired("attack_cooldown") {
state = "chase";
}
}
}
}
log("Debug message");
log_warn("Warning message");
log_error("Error message");
#![allow(unused)]
fn main() {
// Register console command
engine.register_command("spawn_enemy", |args| {
let count = args.get(0).unwrap_or(1);
for _ in 0..count {
spawn("enemy", random_position());
}
});
}
The editor provides:
Breakpoints
Variable inspection
Call stack
Performance profiling
Operation Latency Notes
Script call ~5 µs Per function call
Entity API ~100 ns Property access
Script load ~1 ms Parse + compile
Hot reload ~2 ms Per file
Cache lookups - Don’t find entities every frame
Limit scope - Smaller scripts are faster
Use events - Don’t poll in on_update
Batch operations - Group similar calls
// Bad: Finding player every frame
fn on_update(entity, dt) {
let player = find_nearest("Player"); // Expensive!
}
// Good: Cache reference
let cached_player = null;
fn on_spawn(entity) {
cached_player = find_nearest("Player");
}
fn on_update(entity, dt) {
if cached_player != null {
entity.look_at(cached_player);
}
}
AstraWeave includes over 20 examples demonstrating different aspects of the engine. This page focuses on the working examples that you can build and run to learn the engine.
Note : AstraWeave is under active development. Some examples have compilation issues due to API evolution. This page focuses on examples that are confirmed to work.
These examples demonstrate the AI-native architecture:
Location : examples/hello_companionStatus : ✅ Working (expected panic)
The simplest example of AI perception, planning, and validation.
cargo run -p hello_companion --release
What it demonstrates:
AI perception system capturing world state
LLM-based planning generating intents
Tool validation system (demonstrates failure case)
Fixed-tick simulation loop
Expected behavior : Shows AI plan generation, then panics with “LosBlocked” error. This demonstrates that the AI cannot perform invalid actions.
→ Detailed walkthrough
Location : examples/adaptive_bossStatus : ✅ Working
Multi-phase boss with Director AI that adapts tactics based on player behavior.
cargo run -p adaptive_boss --release
What it demonstrates:
BossDirector for dynamic encounter planning
Budget-constrained AI decisions
Telegraph system for attack warnings
Phase-based combat behavior
→ Detailed walkthrough
Location : examples/companion_profileStatus : ✅ Working
Demonstrates persistent AI profiles that learn and adapt.
cargo run -p companion_profile --release
What it demonstrates:
AI profile serialization/deserialization
Learning from player interactions
Personality trait adjustment
Long-term memory systems
These examples showcase fundamental engine systems:
Location : examples/fluids_demoStatus : ✅ Working
Interactive fluid simulation with PCISPH physics and multiple scenarios.
cargo run -p fluids_demo --release
What it demonstrates:
Real-time particle-based fluid simulation
Multiple scenarios (laboratory, ocean, waterfall, splash)
Interactive particle spawning (click to add water)
LOD optimization for performance scaling
egui debug panel with live parameters
→ Detailed walkthrough
Location : examples/unified_showcaseStatus : ✅ Working
Comprehensive rendering demo with shadows, terrain, GLTF models, and skybox.
cargo run -p unified_showcase --release
What it demonstrates:
Shadow mapping with 2048×2048 depth textures
GLTF model loading with materials
Procedural terrain with multi-texture blending
HDR skybox rendering
4× MSAA antialiasing
→ Detailed walkthrough
Location : examples/physics_demo3dStatus : ✅ Working
Demonstrates the Rapier3D physics integration with character controllers, destructibles, and environmental forces.
cargo run -p physics_demo3d --release
What it demonstrates:
3D physics simulation with Rapier3D
Character controller with slope handling
Destructible objects that shatter
Water buoyancy and wind forces
Collision layer filtering
→ Detailed walkthrough
Location : examples/navmesh_demoStatus : ✅ Working
Shows navigation mesh baking and A* pathfinding.
cargo run -p navmesh_demo --release
What it demonstrates:
NavMesh baking from triangle geometry
Slope-based walkability filtering
A* pathfinding with path visualization
Agent radius margin calculation
→ Detailed walkthrough
Location : examples/audio_spatial_demoStatus : ✅ Working
Spatial audio system with 3D positioning and music crossfading.
cargo run -p audio_spatial_demo --release
What it demonstrates:
3D positional audio (left/center/right beeps)
Music playback with crossfading
Listener tracking tied to camera
Volume bus control (master/music/SFX)
→ Detailed walkthrough
These examples show multiplayer and IPC capabilities:
Location : examples/ipc_loopbackStatus : ✅ Should work
Demonstrates inter-process communication for AI models.
cargo run -p ipc_loopback --release
What it demonstrates:
Local/cloud AI model switching
Process isolation for AI
IPC message passing
AI model hot-swapping
Location : examples/coop_server, examples/coop_clientStatus : ✅ Working
Basic multiplayer client-server architecture.
# Terminal 1
cargo run -p coop_server --release
# Terminal 2
cargo run -p coop_client --release
What it demonstrates:
Server-authoritative validation
Intent-based networking
AI agent synchronization
Anti-cheat through determinism
These examples focus on AI planning and tool usage:
Location : examples/llm_toolcallStatus : ✅ Working
Direct demonstration of LLM tool calling.
cargo run -p llm_toolcall --release
What it demonstrates:
LLM integration
Tool definition and usage
Structured AI responses
Planning validation
Location : examples/phase_directorStatus : ✅ Working
Complex AI director managing multiple phases.
cargo run -p phase_director --release
What it demonstrates:
Multi-phase AI behavior
Director pattern implementation
State machine management
Complex AI coordination
These examples help with engine development:
Location : examples/debug_overlayStatus : ❌ Has compilation issues (egui API)
Debug UI overlay for development.
Known issues : egui API mismatches with current version.
Location : examples/persona_loaderStatus : ✅ Working
Loading and managing AI personas from files.
cargo run -p persona_loader --release
What it demonstrates:
AI persona definition files
Dynamic persona loading
Personality trait configuration
Behavioral parameter tuning
Some examples have known issues due to API evolution:
visual_3d : winit API mismatches
ui_controls_demo : egui API compatibility issues
debug_overlay : egui API changes
rhai_authoring : Depends on broken astraweave-author crate
Issues with rhai sync/send traits
npc_town_demo : Multiple API mismatches
weaving_playground : Dependency issues
cutscene_render_demo : Graphics API issues
To verify your installation is working:
# 1. Build core components
cargo build -p astraweave-core -p astraweave-ai -p hello_companion
# 2. Run the basic example
cargo run -p hello_companion --release
# 3. Run unit tests
cargo test -p astraweave-input
If examples fail to compile:
Check Rust version : rustc --version should match rust-toolchain.toml
Update dependencies : cargo update
Clean build : cargo clean && cargo build
Check system dependencies : Ensure graphics and audio libraries are installed
If you find compilation issues with examples marked as working:
Check your platform and Rust version
Ensure all system dependencies are installed
Try a clean build
Report the issue with full error output
When creating new examples:
// examples/my_example/src/main.rs
use astraweave_core::*;
use astraweave_ai::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize the engine
let mut world = World::new();
// Add your systems
world.add_system(my_custom_system);
// Run the simulation
world.run()?;
Ok(())
}
[package]
name = "my_example"
version.workspace = true
edition.workspace = true
[dependencies]
astraweave-core = { path = "../../astraweave-core" }
astraweave-ai = { path = "../../astraweave-ai" }
anyhow.workspace = true
The working examples are your best introduction to AstraWeave’s capabilities. Start with hello_companion and work your way up to more complex scenarios.
The hello_companion example is the perfect introduction to AstraWeave’s AI-native architecture. This walkthrough explains every step of what happens when you run this example and why it’s designed this way.
cargo run -p hello_companion --release
[INFO] Initializing AstraWeave Engine...
[INFO] Creating world with ECS...
[INFO] Spawning AI companion entity
[INFO] Starting simulation loop at 60Hz
[INFO] Tick 1: Capturing perception snapshot
[INFO] AI perception: 1 entities visible, 0 audio events
[INFO] Sending perception to AI planning layer
[INFO] AI generated plan: MoveTo { target: Vec3(10.0, 0.0, 5.0), urgency: 0.7 }
[INFO] Validating movement tool usage...
[ERROR] Tool validation failed: LosBlocked - No clear line of sight to target
thread 'main' panicked at examples/hello_companion/src/main.rs:42:5:
called `Result::unwrap()` on an `Err` value: ToolValidationError(LosBlocked)
This panic is intentional! It demonstrates AstraWeave’s core principle: AI agents cannot perform actions that violate the game world’s constraints.
Let’s examine the source code to understand each step:
// examples/hello_companion/src/main.rs
use astraweave_core::*;
use astraweave_ai::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
env_logger::init();
// Create the ECS world
let mut world = World::new();
// Configure the engine systems
world.add_plugin(CorePlugin)
.add_plugin(AIPlugin)
.add_plugin(PhysicsPlugin);
What’s happening:
Sets up the Entity-Component-System (ECS) world
Registers core systems for AI, physics, and simulation
Configures 60Hz fixed-tick simulation
#![allow(unused)]
fn main() {
// Spawn an AI companion entity
let companion = world.spawn()
.insert(Position(Vec3::new(0.0, 0.0, 0.0)))
.insert(AIAgent {
perception_range: 10.0,
planning_interval: Duration::from_millis(500),
ai_model: AIModel::Local("companion-7b".to_string()),
})
.insert(MovementCapability {
max_speed: 5.0,
acceleration: 2.0,
})
.id();
info!("Spawned AI companion with ID: {:?}", companion);
}
What’s happening:
Creates a new entity in the ECS world
Adds position component (where the companion is)
Adds AI agent component (makes it intelligent)
Adds movement capability (what it can do)
#![allow(unused)]
fn main() {
// Run simulation for a few ticks
for tick in 0..5 {
info!("Starting tick {}", tick);
// This is where the AI magic happens
world.step(Duration::from_nanos(16_666_667)); // 1/60 second
// Small delay so we can see the output
std::thread::sleep(Duration::from_millis(100));
}
}
What’s happening:
Runs exactly 5 simulation ticks
Each tick advances the world by exactly 1/60th of a second
The deterministic timing ensures consistent behavior
During each world.step() call, several systems run in sequence:
#![allow(unused)]
fn main() {
fn ai_perception_system(
mut query: Query<(&Position, &AIAgent, &mut PerceptionState)>,
world_query: Query<&Position>,
) {
for (pos, agent, mut perception) in query.iter_mut() {
// Gather what the AI can see
let mut visible_entities = Vec::new();
for other_pos in world_query.iter() {
let distance = pos.0.distance(other_pos.0);
if distance <= agent.perception_range {
visible_entities.push(EntityData {
position: other_pos.0,
distance,
entity_type: "unknown".to_string(),
});
}
}
// Create perception snapshot
perception.last_snapshot = Some(PerceptionSnapshot {
timestamp: world.current_tick(),
agent_id: entity,
visible_entities,
audio_events: vec![], // None in this simple example
world_state: WorldState::default(),
});
}
}
}
#![allow(unused)]
fn main() {
fn ai_planning_system(
mut query: Query<(&AIAgent, &PerceptionState, &mut PlanningState)>,
ai_service: Res<AIService>,
) {
for (agent, perception, mut planning) in query.iter_mut() {
if let Some(snapshot) = &perception.last_snapshot {
// Send to AI model for planning
let plan_request = PlanningRequest {
perception: snapshot.clone(),
agent_profile: agent.clone(),
available_tools: vec!["MovementTool", "InteractionTool"],
};
// This is where the LLM generates a plan
let plan = ai_service.generate_plan(plan_request)?;
info!("AI generated plan: {:?}", plan.intent);
planning.current_plan = Some(plan);
}
}
}
}
#![allow(unused)]
fn main() {
fn tool_validation_system(
mut query: Query<(&PlanningState, &mut ActionState)>,
tool_registry: Res<ToolRegistry>,
world: &World,
) {
for (planning, mut action) in query.iter_mut() {
if let Some(plan) = &planning.current_plan {
for tool_usage in &plan.tools {
// This is where the validation happens
let validation_result = tool_registry
.get_tool(&tool_usage.tool_name)
.unwrap()
.validate(world, tool_usage);
match validation_result {
ValidationResult::Valid => {
info!("Tool validation passed: {}", tool_usage.tool_name);
action.pending_actions.push(tool_usage.clone());
}
ValidationResult::Blocked(reason) => {
error!("Tool validation failed: {:?}", reason);
// This causes the panic in hello_companion
return Err(ToolValidationError::from(reason));
}
}
}
}
}
}
}
The panic occurs because the AI tries to move to a position but there’s no clear line of sight. Here’s what happens:
The companion perceives its current position (0, 0, 0)
It detects no obstacles in its perception range
The AI decides it wants to move to position (10, 0, 5)
This seems reasonable based on its limited perception
The MovementTool.validate() method checks line of sight
There’s an invisible obstacle blocking the path
Validation fails with LosBlocked error
The engine refuses to execute the invalid action
This maintains world integrity and prevents AI cheating
The AI doesn’t have perfect information about the world. It can only act based on what it perceives, and all actions must be validated by the engine.
Run the example multiple times - you’ll get the same result every time. This determinism is crucial for:
Reliable testing
Networking (same simulation on all clients)
Debugging AI behavior
The AI doesn’t directly move entities or change the world. It can only request actions through validated tools:
MovementTool (for movement)
InteractionTool (for object interaction)
CombatTool (for attacks)
CommunicationTool (for dialogue)
The AI’s perception is limited and may not match reality. This creates interesting emergent behavior as AI agents must:
Explore to gather information
Make decisions with incomplete data
Adapt when actions fail
To see successful AI behavior, modify the world setup:
#![allow(unused)]
fn main() {
// Remove the obstacle that blocks line of sight
world.remove_obstacle(Vec3::new(5.0, 0.0, 2.5));
// Or give the AI perfect perception
ai_agent.perception_range = f32::INFINITY;
}
#![allow(unused)]
fn main() {
// Add a target for the AI to find
world.spawn()
.insert(Position(Vec3::new(15.0, 0.0, 0.0)))
.insert(InteractableItem {
item_type: "treasure_chest".to_string(),
value: 100,
});
// The AI will now try to navigate to and interact with the chest
}
RUST_LOG=debug cargo run -p hello_companion --release
This shows detailed information about:
ECS system execution order
AI model input/output
Tool validation steps
World state changes
The 60Hz fixed timestep ensures:
Physics determinism
Consistent AI decision making
Reliable networking
Predictable performance
The Entity-Component-System architecture provides:
Cache-friendly performance
Clear separation of concerns
Easy parallel system execution
Modular, testable code
The perception → planning → validation → execution pipeline ensures:
No AI cheating
Consistent game rules
Emergent behavior from constraints
Easy debugging and testing
After understanding hello_companion:
Explore More Examples : Try Adaptive Boss for complex AI
Learn Architecture : Read AI-Native Design
Build Your Own : Follow Building Your First Game
Dive Deeper : Study Core AI Systems
A : The panic demonstrates that validation failures are serious. In a real game, you’d handle this gracefully, but the example uses panic to make the validation concept crystal clear.
A : Yes! You can:
Improve the AI’s perception system
Give it better pathfinding tools
Add obstacle detection to its planning
Implement learning from failed actions
A : For AI-native games, yes! This approach:
Prevents AI cheating
Creates emergent behavior
Works in multiplayer
Enables complex AI interactions
The “failed action = learning opportunity” approach leads to much more interesting AI behavior than scripted sequences.
The hello_companion example may be simple, but it demonstrates the fundamental principles that enable AstraWeave’s AI-native gameplay. Every complex AI behavior in the engine builds on these same validation patterns.
The adaptive_boss example demonstrates AstraWeave’s Director System - an AI-driven game master that orchestrates boss encounters with dynamic phase transitions, telegraphed attacks, and budget-constrained tactical decisions.
cargo run -p adaptive_boss --release
For the full Veilweaver boss experience (requires veilweaver_slice feature):
cargo run -p adaptive_boss --release --features veilweaver_slice
BossDirector : AI that plans boss encounter actions
Phase-based combat : Boss behavior changes based on situation
Director budget : Limited resources for spawns, traps, and terrain edits
Telegraph system : Visual/audio warnings before powerful attacks
OathboundWardenDirector : Full Veilweaver boss with Fate-weaving mechanics
Warden phase: Stalking
Telegraphs: ["Thread gathering at position (5, 3)"]
Director plan: {
"spawn": [{"enemy": "shade", "pos": [4, 1]}],
"traps": [{"type": "thread_snare", "pos": [6, 2]}],
"terrain": []
}
Remaining budget: traps=1, terrain_edits=2, spawns=1
#![allow(unused)]
fn main() {
let mut w = World::new();
// Create the combat arena: player, companion, and boss
let player = w.spawn("Player", IVec2 { x: 2, y: 2 }, Team { id: 0 }, 100, 0);
let comp = w.spawn("Comp", IVec2 { x: 3, y: 2 }, Team { id: 1 }, 80, 30);
let boss = w.spawn("Boss", IVec2 { x: 14, y: 2 }, Team { id: 2 }, 400, 0);
}
The world contains three entities:
Player (HP: 100): The human-controlled character
Companion (HP: 80, Ammo: 30): AI ally
Boss (HP: 400): The adversary with high HP
#![allow(unused)]
fn main() {
let snap = WorldSnapshot {
t: w.t,
player: PlayerState { hp: 100, pos: w.pos_of(player).unwrap(), ... },
me: CompanionState { ammo: 30, morale: 0.8, pos: w.pos_of(comp).unwrap(), ... },
enemies: vec![EnemyState { id: boss, hp: 400, cover: "high", ... }],
pois: vec![],
obstacles: vec![],
objective: Some("defeat_boss".into()),
};
}
The Director receives a perception snapshot containing:
Current game time
Player state (health, position, stance)
Companion state (ammo, morale, cooldowns)
Enemy states (the boss from its perspective)
#![allow(unused)]
fn main() {
let mut budget = DirectorBudget {
traps: 2, // Can place 2 traps
terrain_edits: 2, // Can modify terrain twice
spawns: 2, // Can summon 2 minions
};
}
The budget prevents the Director from overwhelming the player. Each action consumes budget:
Spawns : Summoning minions costs 1 spawn point
Traps : Placing hazards costs 1 trap point
Terrain edits : Blocking paths or creating cover costs 1 edit point
#![allow(unused)]
fn main() {
#[cfg(not(feature = "veilweaver_slice"))]
{
let director = BossDirector;
let plan = director.plan(&snap, &budget);
apply_director_plan(&mut w, &mut budget, &plan, &mut log);
}
}
The basic BossDirector analyzes the world state and generates a plan within budget.
#![allow(unused)]
fn main() {
#[cfg(feature = "veilweaver_slice")]
{
let mut director = OathboundWardenDirector::new();
let directive = director.step(&snap, &budget);
println!("Warden phase: {:?}", directive.phase);
if !directive.telegraphs.is_empty() {
println!("Telegraphs: {:?}", directive.telegraphs);
}
apply_director_plan(&mut w, &mut budget, &directive.plan, &mut log);
}
}
The Oathbound Warden has distinct phases:
Stalking : Observing, placing traps, gathering threads
Weaving : Manipulating fate threads, buffing/debuffing
Severing : Aggressive attacks, breaking player connections
Unraveling : Desperate phase when low HP
┌────────────────────────────────────────────────────────┐
│ BossDirector │
├────────────────────────────────────────────────────────┤
│ Input: WorldSnapshot + DirectorBudget │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ Analyze │ → │ Plan │ → │ Apply within │ │
│ │ Threat │ │ Response │ │ Budget │ │
│ └──────────┘ └──────────┘ └──────────────────┘ │
│ │
│ Output: DirectorPlan { spawns, traps, terrain } │
└────────────────────────────────────────────────────────┘
Telegraphs provide fair warning to players before powerful attacks:
#![allow(unused)]
fn main() {
if !directive.telegraphs.is_empty() {
for telegraph in &directive.telegraphs {
// Display visual/audio warning
// e.g., "Thread gathering at position (5, 3)"
}
}
}
Telegraph types:
Thread gathering : Fate-weaving attack incoming
Ground tremor : Area attack warning
Shadow forming : Minion spawn location
Pattern shift : Phase transition imminent
Unlike traditional boss AI that can spam abilities, the Director operates under resource constraints. This creates:
Strategic decisions : Trade-offs between aggression and defense
Fair encounters : Players can anticipate resource depletion
Dynamic difficulty : Budget recharges over time
The boss doesn’t just cycle through attacks. Phases emerge from:
HP thresholds : Different behavior at 75%, 50%, 25% HP
Player behavior : Adapts to aggressive vs defensive playstyles
Companion effectiveness : Responds to ally threat level
The advanced Warden Director requires the Veilweaver game data:
cargo run -p adaptive_boss --release --features veilweaver_slice
If the Director returns an empty plan, the budget may already be exhausted or the boss doesn’t see any targets.
Example : examples/adaptive_boss/src/main.rs
Director : astraweave-director/src/lib.rs
Warden : astraweave-director/src/oathbound_warden.rs (feature-gated)
The physics_demo3d example showcases AstraWeave’s Rapier3D-based physics system with character controllers, destructible objects, water buoyancy, wind forces, and real-time rendering.
cargo run -p physics_demo3d --release
Character controller : Physics-driven movement with climb detection
Destructible objects : Boxes that break under force
Water simulation : Buoyancy and drag in water volumes
Wind forces : Environmental wind affecting dynamic objects
Collision layers : Filtering which objects collide
Real-time rendering : wgpu-based visualization
Key Action
W/A/S/DMove camera
SpaceCamera up
ShiftCamera down
Right-click + drag Look around
EscExit
Key Action
I/J/K/LMove character (forward/left/back/right)
CAttempt climb (hold near climbable surface)
Key Action
FSpawn dynamic box at (0, 4, 0)
NSpawn destructible box
MBreak last destructible
BSpawn ragdoll placeholder
TToggle wind on/off
GToggle water volume on/off
When you run the demo:
Initial scene : Ground plane, wall, character (green), water volume, destructible box
Press J/L : Character slides left/right with physics
Press F : Box drops and bounces
Press T : Wind pushes boxes in the wind direction
Press G : Water volume toggles, affecting buoyancy
Press M : Destructible box shatters into fragments
#![allow(unused)]
fn main() {
let mut phys = PhysicsWorld::new(vec3(0.0, -9.81, 0.0));
let _ground = phys.create_ground_plane(vec3(100.0, 0.0, 100.0), 1.0);
}
Creates a physics world with Earth gravity (9.81 m/s²)
Adds a ground plane at Y=0
#![allow(unused)]
fn main() {
let _wall = phys.add_static_trimesh(
&[
vec3(5.0, 0.0, 0.0),
vec3(5.0, 3.0, 0.0),
// ... vertices
],
&[[0, 1, 2], [3, 2, 1]], // Triangle indices
Layers::CHARACTER | Layers::DEFAULT,
);
}
Static meshes (walls, floors) don’t move but participate in collisions. The Layers flags control which objects can collide.
#![allow(unused)]
fn main() {
let char_id = phys.add_character(
vec3(-2.0, 1.0, 0.0), // Start position
vec3(0.4, 0.9, 0.4), // Capsule half-extents
);
}
The character controller:
Handles walking on slopes (up to 50° by default)
Provides ground detection and step climbing
Prevents tunneling through walls
#![allow(unused)]
fn main() {
// In the game loop:
let desired = vec3(self.move_dir.x, 0.0, self.move_dir.z);
self.phys.control_character(self.char_id, desired, dt, self.climb_try);
}
The controller converts desired velocity into physics-respecting movement.
#![allow(unused)]
fn main() {
let id = phys.add_destructible_box(
vec3(-1.0, 1.0, 2.0), // Position
vec3(0.4, 0.4, 0.4), // Half-size
3.0, // Mass (kg)
50.0, // Break force threshold
12.0, // Fragment mass
);
}
When break force is exceeded:
#![allow(unused)]
fn main() {
self.phys.break_destructible(id); // Shatters into fragments
}
#![allow(unused)]
fn main() {
phys.add_water_aabb(
vec3(-2.0, 0.0, -2.0), // Min corner
vec3(2.0, 1.2, 2.0), // Max corner
1000.0, // Water density (kg/m³)
0.8, // Drag coefficient
);
}
Objects inside the volume experience:
Buoyancy : Upward force proportional to submerged volume
Drag : Velocity-dependent resistance
#![allow(unused)]
fn main() {
self.phys.set_wind(
vec3(1.0, 0.0, 0.2).normalize(), // Direction
8.0, // Strength
);
}
Wind applies continuous force to dynamic bodies.
#![allow(unused)]
fn main() {
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
// Update physics
self.phys.step();
// Sync render instances from physics transforms
self.instances.clear();
for (handle, _body) in self.phys.bodies.iter() {
if let Some(m) = self.phys.body_transform(id) {
self.instances.push(Instance { transform: m, ... });
}
}
// Render
renderer.update_instances(&self.instances);
renderer.render()?;
}
}
┌─────────────────────────────────────────────────────────┐
│ PhysicsWorld │
├─────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ Rigid Bodies│ │ Colliders │ │ Constraints │ │
│ │ (dynamic) │ │ (shapes) │ │ (joints) │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ Character │ │ Water │ │ Wind/Forces │ │
│ │ Controller │ │ Volumes │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
│ │
│ Backend: Rapier3D 0.22 │
└─────────────────────────────────────────────────────────┘
AstraWeave uses bitflag layers to control collisions:
#![allow(unused)]
fn main() {
pub struct Layers: u32 {
const DEFAULT = 0b0001; // General objects
const CHARACTER = 0b0010; // Player/NPC capsules
const PROJECTILE= 0b0100; // Bullets, arrows
const TRIGGER = 0b1000; // Non-solid triggers
}
}
Objects only collide if their layer masks overlap.
The physics demo runs at 60 FPS with:
~50 dynamic bodies
Character controller
Water volume intersection tests
Wind force application
For larger simulations, consider:
Reducing simulation substeps
Using simpler collision shapes
Spatial partitioning for many bodies
Ensure the ground plane is created before spawning the character.
Check that collision layers overlap between the objects.
Reduce the number of dynamic bodies or increase the physics timestep.
Example : examples/physics_demo3d/src/main.rs (301 lines)
Physics : astraweave-physics/src/lib.rs
Character Controller : astraweave-physics/src/character_controller.rs
The navmesh_demo example demonstrates AstraWeave’s navigation mesh system - baking walkable surfaces from geometry and finding optimal paths with slope constraints.
cargo run -p navmesh_demo --release
NavMesh baking : Converting triangle soup into walkable navigation data
Slope filtering : Excluding steep surfaces from navigation
A pathfinding *: Finding optimal routes across the mesh
Path visualization : Rendering waypoints in 3D space
Key Action
W/A/S/DMove camera
SpaceCamera up
ShiftCamera down
Right-click + drag Look around
EscExit
When you run the demo:
Yellow spheres : Triangle centers of the navigation mesh
Green spheres : Path waypoints from start to goal
Scene geometry : Flat area + ramp + elevated plateau
The path flows from bottom-left (-3.5, 0, -3.5) up the ramp to the plateau (11.5, 0.8, 0).
#![allow(unused)]
fn main() {
let tris = vec![
// Main floor (two triangles forming a quad)
tri(vec3(-4.0, 0.0, -4.0), vec3(4.0, 0.0, -4.0), vec3(4.0, 0.0, 4.0)),
tri(vec3(-4.0, 0.0, -4.0), vec3(4.0, 0.0, 4.0), vec3(-4.0, 0.0, 4.0)),
// Ramp going up (two triangles)
tri(vec3(4.0, 0.0, -1.0), vec3(8.0, 0.8, -1.0), vec3(8.0, 0.8, 1.0)),
tri(vec3(4.0, 0.0, -1.0), vec3(8.0, 0.8, 1.0), vec3(4.0, 0.0, 1.0)),
// Elevated plateau (two triangles)
tri(vec3(8.0, 0.8, -1.0), vec3(12.0, 0.8, -1.0), vec3(12.0, 0.8, 1.0)),
tri(vec3(8.0, 0.8, -1.0), vec3(12.0, 0.8, 1.0), vec3(8.0, 0.8, 1.0)),
];
}
The geometry describes:
Floor : 8×8 meter flat area at Y=0
Ramp : Inclined surface from Y=0 to Y=0.8
Plateau : Elevated area at Y=0.8
#![allow(unused)]
fn main() {
let nav = NavMesh::bake(
&tris, // Triangle geometry
0.4, // Agent radius (meters)
50.0, // Max walkable slope (degrees)
);
}
The baking process:
Voxelizes triangles into a 3D grid
Filters steep surfaces (>50° rejected)
Shrinks walkable area by agent radius (0.4m)
Generates navigation polygon data
#![allow(unused)]
fn main() {
let start = vec3(-3.5, 0.0, -3.5); // Bottom-left of floor
let goal = vec3(11.5, 0.8, 0.0); // Center of plateau
let path = nav.find_path(start, goal);
}
The find_path function:
Projects start/goal onto nearest navmesh triangle
Runs A * across triangle adjacency graph
Returns waypoint list (Vec<Vec3>)
#![allow(unused)]
fn main() {
// Show triangle centers (yellow)
for t in &nav.tris {
instances.push(Instance::from_pos_scale_color(
t.center + vec3(0.0, 0.05, 0.0), // Slightly above surface
vec3(0.1, 0.1, 0.1), // Small sphere
[0.7, 0.7, 0.3, 1.0], // Yellow
));
}
// Show path waypoints (green)
for p in &path {
instances.push(Instance::from_pos_scale_color(
*p + vec3(0.0, 0.08, 0.0),
vec3(0.12, 0.12, 0.12),
[0.2, 1.0, 0.4, 1.0], // Green
));
}
}
┌─────────────────────────────────────────────────────────┐
│ NavMesh │
├─────────────────────────────────────────────────────────┤
│ Input: Vec<Triangle> │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Voxelization │→ │ Slope Filter │→ │ Region Build │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ Output: tris: Vec<NavTri>, adjacency: HashMap │
├─────────────────────────────────────────────────────────┤
│ find_path(start, goal) → Vec\<Vec3\> │
│ ├─ Project points to mesh │
│ ├─ A* search on triangle graph │
│ └─ String-pull path smoothing │
└─────────────────────────────────────────────────────────┘
The max_slope parameter (50° in this example) determines walkability:
Surface Angle Walkable? (50° max)
0° (flat) ✅ Yes
30° (gentle) ✅ Yes
50° (steep) ✅ Yes (boundary)
60° (cliff) ❌ No
90° (wall) ❌ No
Steep surfaces are excluded from the navigation graph.
The agent_radius (0.4m) shrinks walkable areas:
Before shrinking: After shrinking:
┌────────────────┐ ┌────────────────┐
│ │ │ ┌────────┐ │
│ Walkable │ → │ │Walkable│ │
│ │ │ └────────┘ │
└────────────────┘ └────────────────┘
↑ 0.4m margin
This prevents agents from clipping through walls.
#![allow(unused)]
fn main() {
pub struct Triangle {
pub a: Vec3, // First vertex
pub b: Vec3, // Second vertex
pub c: Vec3, // Third vertex
}
}
#![allow(unused)]
fn main() {
pub struct NavTri {
pub center: Vec3, // Triangle centroid
pub normal: Vec3, // Surface normal
pub neighbors: Vec<usize>, // Adjacent triangle indices
}
}
NavMesh baking is an offline process:
Bake time : ~10ms for simple geometry, seconds for complex levels
Runtime pathfinding : <1ms for typical paths
Memory : ~100 bytes per navigation triangle
For large worlds, consider:
Pre-baking navmeshes at build time
Hierarchical navigation (coarse + fine meshes)
Path caching for frequently-used routes
Check that start/goal are near the navmesh surface
Verify the mesh is connected (no isolated islands)
Increase agent_radius parameter
Ensure wall geometry is included in navmesh input
Increase max_slope angle (up to 89°)
Example : examples/navmesh_demo/src/main.rs (196 lines)
NavMesh : astraweave-nav/src/navmesh.rs
Pathfinding : astraweave-nav/src/pathfinder.rs
The audio_spatial_demo example demonstrates AstraWeave’s 3D spatial audio system - positional sound effects, music playback with crossfading, and listener tracking tied to the camera.
cargo run -p audio_spatial_demo --release
Note : This demo requires audio files in assets/audio/. If files are missing, you’ll see error messages but the demo will still run for visualization.
3D positional audio : Sounds panning based on listener position
Procedural beeps : Generated tones at specified frequencies
Music playback : Looped background music
Crossfading : Smooth transitions between music tracks
Listener tracking : Audio follows camera position/orientation
Key Action
W/A/S/DMove camera (and listener)
SpaceCamera up
ShiftCamera down
Right-click + drag Look around
1Play beep at center (880 Hz)
2Play beep at left (-3, 1, 0) at 660 Hz
3Play beep at right (+3, 1, 0) at 440 Hz
MSwitch music track (crossfade)
EscExit
Launch : Background music plays (if assets/audio/bgm.ogg exists)
Press 1 : High-pitched beep plays from center
Press 2 : Medium-pitched beep plays from left (pans left in headphones)
Press 3 : Low-pitched beep plays from right (pans right)
Move camera with W/A/S/D : Audio panning shifts as listener moves
Press M : Music crossfades to alternate track
#![allow(unused)]
fn main() {
let mut audio = AudioEngine::new()?;
audio.set_master_volume(1.0);
// Try to play looped background music
let _ = audio.play_music(
MusicTrack {
path: "assets/audio/bgm.ogg".into(),
looped: true,
},
1.0, // Volume
);
}
The AudioEngine:
Initializes the audio device (rodio backend)
Manages multiple simultaneous sounds
Controls master/music/SFX volume buses
#![allow(unused)]
fn main() {
// Center beep (880 Hz, 0.25s duration, 0.5 volume)
self.audio.play_sfx_3d_beep(
100, // Emitter ID
vec3(0.0, 1.0, 0.0), // Position (center)
880.0, // Frequency (Hz)
0.25, // Duration (seconds)
0.5, // Volume
);
// Left beep (660 Hz)
self.audio.play_sfx_3d_beep(
101,
vec3(-3.0, 1.0, 0.0), // Left of center
660.0,
0.25,
0.5,
);
// Right beep (440 Hz)
self.audio.play_sfx_3d_beep(
102,
vec3(3.0, 1.0, 0.0), // Right of center
440.0,
0.25,
0.5,
);
}
The emitter ID allows tracking/stopping specific sounds.
#![allow(unused)]
fn main() {
self.audio.play_music(
MusicTrack {
path: "assets/audio/bgm_alt.ogg".into(),
looped: true,
},
1.25, // Crossfade duration (seconds)
);
}
When switching tracks:
Current music fades out over 1.25 seconds
New music fades in simultaneously
No audio gap or jarring transition
#![allow(unused)]
fn main() {
// Calculate forward direction from camera yaw/pitch
let forward = glam::Quat::from_euler(
glam::EulerRot::YXZ,
self.camera.yaw,
self.camera.pitch,
0.0,
) * vec3(0.0, 0.0, -1.0);
// Update audio listener pose
self.audio.update_listener(ListenerPose {
position: self.camera.position,
forward,
up: vec3(0.0, 1.0, 0.0),
});
// Advance audio processing
self.audio.tick(dt);
}
The listener pose determines:
Position : Where sounds are heard from
Forward : Which direction is “front”
Up : Which direction is “up” (for proper panning)
┌─────────────────────────────────────────────────────────┐
│ AudioEngine │
├─────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ Music Bus │ │ SFX Bus │ │ Voice Bus │ │
│ │ (looped) │ │ (one-shot) │ │ (dialogue) │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
│ ↓ │
│ ┌─────────────────┐ │
│ │ Spatial Mixer │ ← ListenerPose │
│ │ (3D panning) │ │
│ └─────────────────┘ │
│ ↓ │
│ ┌─────────────────┐ │
│ │ Master Volume │ │
│ └─────────────────┘ │
│ ↓ │
│ ┌─────────────────┐ │
│ │ rodio Output │ │
│ └─────────────────┘ │
│ │
│ Backend: rodio 0.17 │
└─────────────────────────────────────────────────────────┘
#![allow(unused)]
fn main() {
pub struct ListenerPose {
pub position: Vec3, // World-space position
pub forward: Vec3, // Look direction (normalized)
pub up: Vec3, // Up vector (typically Y-up)
}
}
The audio system uses this to compute:
Attenuation : Sounds get quieter with distance
Panning : Left/right based on angle to listener
Doppler (optional): Pitch shift for moving sources
#![allow(unused)]
fn main() {
// Individual bus control
audio.set_master_volume(1.0); // Overall volume
audio.set_music_volume(0.8); // Background music
audio.set_sfx_volume(1.0); // Sound effects
audio.set_voice_volume(1.0); // Dialogue/narration
}
AstraWeave audio supports:
OGG Vorbis (recommended for music)
WAV (uncompressed, good for short SFX)
MP3 (widely compatible)
FLAC (lossless, large files)
Simultaneous sounds : Up to 64 by default
Streaming : Large files (>1MB) stream from disk
CPU usage : <1% for typical game audio
Memory : ~10KB per short sound, streaming for long tracks
Check system audio settings
Verify audio device is connected
Try different audio files
Increase audio buffer size
Reduce simultaneous sound count
Check for CPU throttling
Verify update_listener() is called each frame
Check that forward/up vectors are normalized
Ensure sound positions are in world space
The demo will print errors if assets/audio/bgm.ogg is missing. Create the directory and add audio files, or the demo will run silently.
Example : examples/audio_spatial_demo/src/main.rs (215 lines)
Audio Engine : astraweave-audio/src/engine.rs
Spatial Mixer : astraweave-audio/src/spatial.rs
Interactive demonstration of AstraWeave’s PCISPH-based fluid simulation system with real-time rendering, multiple scenarios, and egui-powered controls.
cargo run -p fluids_demo --release
The fluids demo showcases:
Real-time particle-based fluid simulation using PCISPH solver
Multiple simulation scenarios (laboratory, ocean, waterfall, splash)
Interactive controls for spawning particles and adjusting parameters
Advanced rendering with caustics, foam, and refraction effects
LOD optimization for performance scaling
# Run the demo
cargo run -p fluids_demo --release
# Controls:
# WASD - Move camera
# Mouse - Rotate camera
# LMB - Spawn particles at cursor
# RMB - Apply force to particles
# Tab - Switch scenario
# F3 - Toggle debug panel
graph TD
A[FluidSystem] --> B[PCISPH Solver]
A --> C[FluidRenderer]
A --> D[ScenarioManager]
B --> E[Particle Grid]
B --> F[Pressure Solve]
B --> G[Viscosity]
C --> H[GPU Rendering]
C --> I[Caustics]
C --> J[Foam Effects]
D --> K[Laboratory]
D --> L[Ocean]
D --> M[Waterfall]
D --> N[Splash]
Controlled environment for testing fluid behavior:
Bounded container simulation
Particle spawning controls
Parameter visualization
Ideal for learning system behavior
Large-scale ocean wave simulation:
Skybox environment (HDR-based)
Wave dynamics
Foam generation
Infinite horizon effect
Vertical fluid flow demonstration:
Gravity-driven particle flow
Splash effects at base
Continuous particle recycling
High-energy impact simulation:
Droplet spawning
Surface tension effects
Spray particle generation
The demo exposes all major fluid parameters through the debug panel:
#![allow(unused)]
fn main() {
// From the demo - actual parameter configuration
fluid_system.smoothing_radius = 0.5; // SPH kernel radius
fluid_system.target_density = 1.0; // Rest density
fluid_system.pressure_multiplier = 100.0; // Stiffness
fluid_system.viscosity = 0.01; // Fluid thickness
fluid_system.gravity = -9.81; // Gravity strength
fluid_system.cell_size = 1.2; // Spatial hash cell size
fluid_system.grid_width = 64; // Grid dimensions
fluid_system.grid_height = 64;
fluid_system.grid_depth = 64;
}
The demo includes 4 quality levels (selectable in UI):
Preset Particles Cell Size Target FPS
Low 5,000 2.0 60+
Medium 10,000 1.5 60
High 20,000 1.2 45-60
Ultra 50,000 1.0 30-45
Left-click in the viewport to spawn particles:
#![allow(unused)]
fn main() {
// Demo implementation - ray-plane intersection for spawn position
fn spawn_particles_at_cursor(&mut self) {
let (origin, dir) = self.screen_to_world_ray(
self.mouse_pos[0],
self.mouse_pos[1]
);
// Intersect with Y=5 plane (fluid center height)
if let Some(hit_pos) = self.ray_plane_intersection(origin, dir, 5.0) {
let positions: Vec<[f32; 3]> = (0..count)
.map(|i| {
let angle = (i as f32 / count as f32) * TAU;
let radius = (i as f32 * 0.1).sin() * 0.5;
[hit_pos.x + angle.cos() * radius,
hit_pos.y + 0.5,
hit_pos.z + angle.sin() * radius]
})
.collect();
self.fluid_system.spawn_particles(&queue, &positions, &velocities, colors);
}
}
}
Right-click and drag to apply forces:
Drag direction determines force vector
Force magnitude scales with drag distance
Affects all particles in a configurable radius
Press F3 to toggle the debug panel (egui-based):
Real-time particle count
FPS / frame time graph
Simulation step timing
LOD level indicator
Parameter sliders
The demo uses FluidLodManager and FluidOptimizationController for dynamic quality scaling:
#![allow(unused)]
fn main() {
// LOD configuration from demo
let lod_config = FluidLodConfig {
target_fps: 60.0,
min_particles: 1000,
max_particles: 50000,
lod_levels: vec![
LodLevel { distance: 10.0, particle_scale: 1.0 },
LodLevel { distance: 25.0, particle_scale: 0.5 },
LodLevel { distance: 50.0, particle_scale: 0.25 },
],
};
// Controller maintains target FPS
optimization_controller.update(last_frame_time_ms);
let suggested_count = optimization_controller.suggested_particle_count();
}
The demo uses wgpu 25 for all rendering:
#![allow(unused)]
fn main() {
// Render pass structure
let fluid_renderer = FluidRenderer::new(&device, width, height, format);
// Each frame:
// 1. Render skybox to scene texture (if ocean scenario)
// 2. Render fluid particles with refraction
// 3. Apply caustics and foam effects
// 4. Composite to swapchain
// 5. Render egui overlay
}
The demo includes multiple WGSL shaders:
fluid.wgsl - Main particle rendering
ocean.wgsl - Ocean surface simulation
skybox.wgsl - HDR environment mapping
glass.wgsl - Refraction effects
Reduce particle count - Start with Medium preset
Increase cell size - Fewer neighbor lookups
Use LOD - Enable optimization controller
Disable foam - Reduces particle overhead
Enable Tracy profiling:
cargo run -p fluids_demo --release --features profiling
Key spans to watch:
fluid_system.step() - Physics simulation
fluid_renderer.render() - GPU draw calls
egui.render() - UI overhead
[dependencies]
astraweave-physics = { path = "../../astraweave-physics" }
astraweave-fluids = { path = "../../astraweave-fluids" }
winit = { workspace = true }
wgpu = { workspace = true }
glam = { workspace = true }
egui = { workspace = true }
egui-wgpu = { workspace = true }
egui-winit = { workspace = true }
Symptom : Frame rate drops below 30 FPS
Solutions :
Switch to Low quality preset
Reduce spawn_burst_size in debug panel
Close other GPU-intensive applications
Check GPU driver is up to date
Symptom : Scene renders but no fluid particles
Solutions :
Check scenario is initialized (Tab to switch)
Click LMB to spawn particles manually
Verify camera position (reset with R key)
Symptom : Ocean scenario shows black sky
Cause : Missing HDR file at assets/hdri/polyhaven/kloppenheim_02_puresky_2k.hdr
Solution : Download from Poly Haven or disable skybox feature
Symptom : “No suitable adapter found”
Solutions :
Update GPU drivers
Try different backend: WGPU_BACKEND=vulkan cargo run -p fluids_demo
Ensure system meets minimum requirements (DX12 or Vulkan 1.1 capable GPU)
A comprehensive rendering demonstration combining shadows, terrain, GLTF models, and skybox rendering in a self-contained wgpu application.
cargo run -p unified_showcase --release
The unified showcase demonstrates:
Shadow mapping with 2048×2048 depth textures
GLTF model loading with materials and transforms
Procedural terrain with multi-texture blending
HDR skybox rendering
4× MSAA antialiasing
First-person camera controls
This is a standalone renderer example that doesn’t depend on engine render crates—ideal for learning wgpu patterns.
# Run the showcase
cargo run -p unified_showcase --release
# Controls:
# WASD - Move camera
# Mouse - Look around (click to capture)
# Escape - Release mouse
# Space - Move up
# Shift - Move down
Real-time shadow casting with PCF (Percentage Closer Filtering):
#![allow(unused)]
fn main() {
// Shadow texture configuration
let shadow_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shadow Texture"),
size: wgpu::Extent3d {
width: 2048,
height: 2048,
depth_or_array_layers: 1,
},
format: wgpu::TextureFormat::Depth32Float,
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
...
});
// Comparison sampler for PCF
let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
compare: Some(wgpu::CompareFunction::Less),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
...
});
}
The demo includes a custom GLTF loader for scene objects:
#![allow(unused)]
fn main() {
// Load and render GLTF models
mod gltf_loader;
// Material indices for different model parts
pine_bark_mat: usize,
pine_leaves_mat: usize,
tower_wood_mat: usize,
tower_stone_mat: usize,
}
Supported GLTF features:
Mesh geometry (positions, normals, UVs)
Base color textures
Transform hierarchies
Multiple materials per model
Procedural terrain with height-based texture blending:
Grass texture - Low elevation areas
Rock texture - Steep slopes
Snow texture - High elevation peaks
Normal mapping - Surface detail
Triplanar projection - Seamless texturing on steep surfaces
// terrain.wgsl - height-based texture blending
fn blend_terrain_textures(height: f32, slope: f32) -> vec4<f32> {
let grass_weight = smoothstep(0.0, 0.3, 1.0 - slope);
let rock_weight = smoothstep(0.2, 0.5, slope);
let snow_weight = smoothstep(0.7, 1.0, height);
return grass * grass_weight + rock * rock_weight + snow * snow_weight;
}
HDR-based environment rendering:
#![allow(unused)]
fn main() {
// Skybox pipeline - renders at depth 1.0 (far plane)
let sky_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
depth_stencil: Some(wgpu::DepthStencilState {
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::LessEqual,
...
}),
...
});
}
4× multisampling for smooth edges:
#![allow(unused)]
fn main() {
multisample: wgpu::MultisampleState {
count: 4,
mask: !0,
alpha_to_coverage_enabled: false,
}
}
graph TD
A[ShowcaseApp] --> B[Render Pipelines]
A --> C[Scene Data]
A --> D[Camera System]
B --> E[Main Pipeline]
B --> F[Sky Pipeline]
B --> G[Terrain Pipeline]
B --> H[Shadow Pipeline]
C --> I[Meshes]
C --> J[Materials]
C --> K[Scene Objects]
E --> L[MSAA Texture]
L --> M[Resolve to Swapchain]
Shadow Pass - Render scene from light perspective
Skybox Pass - Render environment (no depth write)
Terrain Pass - Render ground with shadows
Object Pass - Render GLTF models with shadows
MSAA Resolve - Resolve 4× samples to swapchain
#![allow(unused)]
fn main() {
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[Vertex::desc()],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
}),
multisample: wgpu::MultisampleState { count: 4, ... },
});
}
The showcase uses 4 main bind group layouts:
Slot Name Contents
0 Camera View-projection matrix, camera position
1 Light Light view-projection, position, color, shadow map
2 Material Albedo texture, sampler
3 Model Per-object transform matrix
#![allow(unused)]
fn main() {
#[repr(C)]
struct Vertex {
position: [f32; 3],
normal: [f32; 3],
uv: [f32; 2],
color: [f32; 4],
tangent: [f32; 4],
}
}
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world_pos = model.model * vec4<f32>(in.position, 1.0);
out.clip_position = camera.view_proj * world_pos;
out.world_position = world_pos.xyz;
out.world_normal = normalize((model.model * vec4<f32>(in.normal, 0.0)).xyz);
out.uv = in.uv;
out.shadow_position = light.view_proj * world_pos;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let shadow = calculate_shadow(in.shadow_position);
let albedo = textureSample(t_diffuse, s_diffuse, in.uv);
let lighting = calculate_lighting(in.world_normal, light.position, shadow);
return vec4<f32>(albedo.rgb * lighting, albedo.a);
}
Minimal shader for depth-only rendering:
@vertex
fn vs_main(in: VertexInput) -> @builtin(position) vec4<f32> {
return light.view_proj * model.model * vec4<f32>(in.position, 1.0);
}
[dependencies]
winit = { workspace = true }
wgpu = { workspace = true }
glam = { workspace = true }
pollster = { workspace = true }
bytemuck = { workspace = true }
image = { version = "0.25", features = ["png", "jpeg", "hdr", "webp"] }
gltf = { version = "1.4", features = ["utils", "KHR_materials_unlit"] }
Note: This example is self-contained and doesn’t use astraweave-render crates. It’s designed as a clean reference implementation.
The demo expects assets in the following locations:
assets/
├── models/
│ ├── pine_tree.glb
│ └── watch_tower.glb
├── textures/
│ ├── grass_albedo.png
│ ├── rock_albedo.png
│ └── snow_albedo.png
└── hdri/
└── polyhaven/
└── kloppenheim_02_puresky_2k.hdr
Typical performance on mid-range hardware:
Metric Value
Shadow Resolution 2048×2048
MSAA 4×
Draw Calls 10-50 (scene dependent)
Frame Time 8-12 ms (60-80+ FPS)
VRAM ~200-500 MB
For production use, consider:
Cascaded Shadow Maps - Multiple shadow cascades for large scenes
Instanced Rendering - Batch similar objects
Frustum Culling - Skip off-screen objects
LOD System - Distance-based mesh simplification
This example teaches:
wgpu Pipeline Creation - Complete setup from device to swapchain
Bind Group Management - Proper resource binding patterns
Shadow Mapping - Light-space rendering and PCF sampling
GLTF Integration - Loading and rendering real 3D assets
Multi-Pass Rendering - Coordinating multiple render passes
MSAA Setup - Multisampling with resolve
Symptom : Window opens but shows only black
Solutions :
Check GPU supports wgpu backends (Vulkan/DX12/Metal)
Update graphics drivers
Enable logging: RUST_LOG=info cargo run -p unified_showcase
Symptom : Models render as solid colors
Cause : Asset files not found
Solution : Ensure assets/ directory contains required textures
Symptom : FPS below 30
Solutions :
Reduce shadow map resolution (edit source)
Disable MSAA (change count to 1)
Close other GPU applications
Try different wgpu backend: WGPU_BACKEND=dx12
Symptom : Panic on startup with GLTF error
Solutions :
Verify GLTF files exist in assets/models/
Check GLTF files are valid (test in Blender)
Ensure GLTF uses supported features only
This guide helps resolve common issues when running AstraWeave examples.
The following examples are verified to build and run:
Example Command Notes
hello_companion cargo run -p hello_companion --releaseExpected panic (demonstrates tool validation)
fluids_demo cargo run -p fluids_demo --releaseInteractive PCISPH fluid simulation
unified_showcase cargo run -p unified_showcase --releasewgpu rendering with shadows, terrain
physics_demo3d cargo run -p physics_demo3d --releaseRapier3D physics with character controller
navmesh_demo cargo run -p navmesh_demo --releaseNavMesh baking and A* pathfinding
audio_spatial_demo cargo run -p audio_spatial_demo --release3D spatial audio
adaptive_boss cargo run -p adaptive_boss --releaseDirector AI for boss encounters
ipc_loopback cargo run -p ipc_loopback --releaseIPC communication test
Some examples have compilation issues due to API evolution:
Example Issue Status
debug_overlay egui 0.32 API changes Needs update
ui_controls_demo egui/winit version mismatches Needs update
visual_3d winit 0.30 migration Needs update
rhai_authoring Rhai sync/send traits Needs refactor
npc_town_demo Multiple API mismatches Needs update
Use these workspace aliases for safer builds:
cargo build-core
cargo check-all
cargo clippy-all
thread 'main' panicked at 'No suitable graphics adapter found'
Solution : Update graphics drivers or use software rendering:
export WGPU_BACKEND=gl
cargo run -p hello_companion
Error: Could not load AI model
Solution : Examples use mock AI by default. This panic is expected when no LLM is configured.
sudo apt-get install mesa-vulkan-drivers vulkan-tools
Install Visual Studio with C++ build tools.
Performance
Performance Benchmarks
All benchmarks executed via Criterion.rs statistical benchmarking.
Results sourced from the Master Benchmark Report v5.55 (January 2026).
Every measurement can be reproduced with documented commands.
~1,500 benchmarks across 76 sections
Criterion.rs + Real Ollama validation
Production Ready (Grade A+)
85%
p99 headroom at 1,000 entities @ 60 FPS
15.0µs
ECS entity spawn (100), 70% under target
286ns
GOAP planning (full), 97% under target
9.8ns
GOAP cache hit, 99% under target
1.27ms
p50 frame time at 1,000 entities
2.42ms
p99 frame time at 1,000 entities
Executive summary
Key validation results — January 2026
Engine has 85% headroom at p99 for 1,000 entities at 60 FPS.
All core systems operate well under their per-frame budgets.
System Benchmark Target Actual Margin
ECS Entity spawn (100) <50µs 15.0µs 70% under
ECS Entity spawn (1000) <500µs 106.7µs 79% under
AI GOAP planning (full) <10µs 286ns 97% under
AI GOAP planning (cache) <1µs 9.8ns 99% under
Frame p50 @ 1k entities <16.67ms 1.27ms 92% under
Frame p99 @ 1k entities <16.67ms 2.42ms 85% under
Best performers
Operations achieving sub-nanosecond and sub-10ns latency.
These benchmarks represent AstraWeave's highest-performing operations,
many achieving sub-nanosecond latency.
Sub-nanosecond
Operations under 1 ns
Operation Latency Throughput
Multi-Agent Per-Agent 12-20 ps 50-83 trillion/sec
Nav Sliver Triangles 99-104 ps 10 billion/sec
Multi-Agent Per-Plan 0.29-0.31 ns 3.2-3.4 billion/sec
Pan Mode Switching 418 ps —
State Transitions 0.49-0.51 ns —
Emotion Blending 0.55 ns —
Multi-Agent Feedback 0.73-0.76 ns 1.3 billion/sec
MSAA Resize 720p 582-645 ps —
UI Settings Nav 696 ps —
Clear Frame 0.72 ns —
Weather Attenuation 730-783 ps 22.8 billion/frame
Room Overlap Check 571-629 ps —
Frustum AABB Inside 889-915 ps —
GPU Budget Check 890 ps-1.05 ns 17 billion/frame
Sub-10 nanosecond
Operations under 10 ns
Operation Latency Notes
SparseSet Lookup (1k) 1.56 ns 37× faster than BTreeMap
SIMD Movement 1.73 ns 2.26× faster than naive
Quat Multiply 1.34 ns glam SIMD-optimized
Quat Slerp 2.10 ns Rotation interpolation
Context Switching 2.38 ns 7M switches/frame
GOAP (no enemies) 3.46-3.56 ns Idle detection FREE
Component Deserialize 3.50 ns Postcard ECS
Physics Stage 3.63 ns 7,580× vs perception
RAG Engine Creation 4.61 ns Zero-cost abstraction
Mat4 Multiply 4.28 ns glam SIMD matrix
GOAP (close) 4.68-5.11 ns Tactical decision
GOAP (far) 7.04-7.86 ns Strategic decision
SparseSet Insert 9.9 ns 13× faster than BTreeMap
Core systems
Engine subsystem benchmarks
Each subsystem is benchmarked independently with Criterion.rs.
Results include budget analysis against the 16.67ms frame budget.
ECS
Entity-Component-System
Benchmark Result Budget
Spawn empty (10k) 645µs Excellent
Spawn + Position (10k) 5.6ms Production
Despawn empty (10k) 287µs Fixed
Despawn + comp (10k) 2.5ms 68% faster
Iteration (10k) 273µs Excellent
Archetype trans (10k) 5.6ms Within budget
AI
Planning and orchestration
Benchmark Result Notes
GOAP (cache hit) 9.8 ns 99% under
GOAP (cache miss) 286 ns 97% under
GOAP next (idle) 3.5 ns 4.7B ops/frame
GOAP next (close) 5.1 ns 3.5B ops/frame
GOAP next (far) 7.9 ns 2.4B ops/frame
Multi-agent (10) 1.34-1.39µs 66-68% faster
Arbiter GOAP 101.7 ns 982× faster
Arbiter LLM 575 ns 86× faster
Mode transition 221.9 ns 45× faster
Physics
Simulation and collision
Benchmark Result Notes
Character move 43.8-52.0 ns 12-26% faster
Rigid body lookup 14.8-15.4 ns 10× vs character
Raycast (empty) 26.3-31.5 ns 8-23% faster
Rigid body batch 47µs Excellent
Spatial hash 99.96% fewer Grid optimization
Fluids
SPH simulation (A+ grade)
Benchmark Result Notes
Particles (1K-10K) 5.3-110µs 100-322 Melem/s
Spatial hashing 163µs-5.6ms 38-62% improved
SPH kernels (100K) 171-223µs poly6/spiky
Density/pressure 3.5-10.5ms —
Sim step (1K) 1.8-3.0ms —
Multi-step 450-500µs 45-57% faster
GPU data prep 0.9-2.6 ns Sub-nanosecond
Rendering
wgpu pipeline benchmarks
Category Benchmark Result
Culling AABB inside 889-915 ps
Culling Contains point 951 ps-1.01 ns
MSAA Mode check 795-842 ps
MSAA Resize 720p 582-645 ps
Camera View matrix 4.42-5.36 ns
Camera Toggle mode 1.72-2.29 ns
Instancing Savings calc 1.43-1.52 ns
Weather Particle 1.95-2.04 ns
Weather Attenuation 730-783 ps
Animation and navigation
Interpolation and pathfinding
Benchmark Result Notes
vec3_lerp 1.69-1.83 ns 57% faster
quat_to_rotation 1.63-1.73 ns 36% faster
Tween update 22.1 ns —
Spring update 14.2 ns 1.6× vs tween
Sliver triangles 99-104 ps Sub-nanosecond
Impossible paths 3.7-24.9µs Fast-fail
Maze stress 1.6-108µs —
Pathfind short 7.5µs Excellent
Frame budget analysis
Target: 60 FPS = 16.67ms per frame
Budget breakdown at 1,000 entities shows the engine using only 14.5% of the
available frame time at p99, leaving 85% headroom for gameplay logic and rendering.
Budget breakdown (1,000 entities)
System Time Budget % Status
ECS Core 85 µs 0.51% ✅
AI (500 agents) 471 µs 2.83% ✅
Physics (100 bodies) 47 µs 0.28% ✅
Core loop (5k) 529 µs 3.17% ✅
p50 Total 1.27 ms 7.6% ✅
p99 Total 2.42 ms 14.5% ✅
Headroom 14.25 ms 85% ✅
Scalability projections
Entity Count p99 Estimate Feasibility
1,000 2.42 ms ✅ 85% headroom
5,000 ~8-10 ms ✅ 40-50% headroom
10,000 ~15-18 ms ⚠️ Near budget
20,000+ >30 ms ❌ Requires 30 FPS
60 FPS capacity
ECS time at scale
Entity Count ECS Time Budget Used
1,000 ~85µs 0.51%
5,000 ~529µs 3.17%
10,000 ~1ms ~6%
Reproduce locally
Running benchmarks
Every benchmark can be reproduced with a single command.
Criterion.rs provides confidence intervals and statistical rigor.
Full suite
# Run all Criterion benchmarks
cargo bench --workspace
# Run with odyssey automation (captures logs)
./scripts/benchmark_odyssey.ps1 \
-OutDir benchmark_results/$(Get-Date -Format 'yyyy-MM-dd')
Per-crate benchmarks
# ECS benchmarks
cargo bench -p astraweave-ecs
# AI benchmarks
cargo bench -p astraweave-ai
# Physics benchmarks
cargo bench -p astraweave-physics
# Render benchmarks
cargo bench -p astraweave-render
Generating HTML reports
# Open Criterion HTML report
cargo bench -p astraweave-ecs -- --save-baseline main
# Reports at: target/criterion/*/report/index.html
Philosophy
Benchmarks as verification artifacts, not marketing numbers.
Reproducibility
Every claimed measurement has a command that reproduces it.
Raw logs
All runs capture raw output for auditing.
Statistical rigor
Criterion.rs provides confidence intervals.
Adversarial testing
22 adversarial benchmark sections stress edge cases.
Real hardware
No synthetic workloads — real game scenarios.
This document describes how AstraWeave performance measurements are collected, validated, and reported.
“Prove it, don’t hype it.”
Every performance claim in AstraWeave documentation:
Has a command that reproduces it
Captures raw logs for auditing
Uses statistical analysis for reliability
Is validated against real workloads, not synthetic benchmarks
All microbenchmarks use Criterion.rs for statistical rigor.
Why Criterion :
Statistical analysis with confidence intervals
Outlier detection and filtering
Baseline comparison (catch regressions)
HTML report generation
Location : target/criterion/**/base/estimates.json
For full-suite benchmarking, use the automation script:
./scripts/benchmark_odyssey.ps1 -OutDir benchmark_results/$(Get-Date -Format 'yyyy-MM-dd')
Outputs :
environment.txt - OS/CPU/RAM, rustc/cargo version, git SHA
packages_with_benches.txt - Inventory of benchmarked crates
run_order.txt - Execution order
bench_<package>.log - Raw benchmark output per crate
run_results.json - Success/fail status
Criterion provides 95% confidence intervals for all measurements. We report:
Point estimate : The measured mean
Lower bound : 95% CI lower
Upper bound : 95% CI upper
Example: 1.34 ns [1.33 ns, 1.35 ns] means the true mean is 95% likely within that range.
Default Criterion settings:
Warm-up : 3 seconds (eliminates cold-start artifacts)
Measurement : 5 seconds minimum
Sample size : 100 samples minimum
Criterion automatically detects and reports outliers:
Mild outliers : 1.5× IQR
Severe outliers : 3× IQR
Outliers are flagged in reports but included in analysis (not discarded).
Single-operation measurements (e.g., “how long does vec3_lerp take?”).
Location : crates/*/benches/*.rs
Example :
#![allow(unused)]
fn main() {
fn bench_vec3_lerp(c: &mut Criterion) {
let a = Vec3::new(0.0, 0.0, 0.0);
let b = Vec3::new(1.0, 1.0, 1.0);
c.bench_function("vec3_lerp", |bencher| {
bencher.iter(|| a.lerp(b, 0.5))
});
}
}
Stress tests for edge cases and worst-case scenarios.
Categories (22 sections):
Gameplay adversarial (massive damage, rapid hits)
Input adversarial (input storms, frame clear)
Math adversarial (IEEE-754 edge cases: infinity, NaN, denormals)
Navigation adversarial (sliver triangles, impossible paths)
Security adversarial (script sandboxing, anti-cheat)
And 17 more…
Purpose : Ensure production stability under extreme conditions.
End-to-end measurements of complete systems.
Example : “Full game loop with 5,000 entities”
#![allow(unused)]
fn main() {
fn bench_full_game_loop(c: &mut Criterion) {
let mut world = setup_world_with_entities(5000);
c.bench_function("full_game_loop/5000_entities", |bencher| {
bencher.iter(|| world.tick(1.0 / 60.0))
});
}
}
Measure performance across varying input sizes.
Example : Entity spawn at 10, 100, 1000, 10000 entities.
#![allow(unused)]
fn main() {
fn bench_entity_spawn(c: &mut Criterion) {
let mut group = c.benchmark_group("entity_spawn");
for size in [10, 100, 1000, 10000] {
group.bench_with_input(
BenchmarkId::from_parameter(size),
&size,
|b, &size| b.iter(|| spawn_entities(size))
);
}
group.finish();
}
}
Benchmark machines should document:
CPU : Model, cores, clock speed
RAM : Size, speed
OS : Windows/Linux/macOS version
Rust : rustc --version
Profile : Always --release
For reliable measurements:
Close unnecessary applications
Disable turbo boost (optional, for consistency)
Run multiple times to verify reproducibility
Use cargo bench -- --noplot to skip HTML generation (faster)
All benchmark results are consolidated in:
docs/masters/MASTER_BENCHMARK_REPORT.md
Update triggers :
Any benchmark changes >10%
New benchmarks added
Performance regressions discovered
Each report version documents:
Version number (e.g., v5.55)
Date of measurement
Key changes since last version
Critical fixes applied
# Save current as baseline
cargo bench -p astraweave-ecs -- --save-baseline main
# Compare against baseline
cargo bench -p astraweave-ecs -- --baseline main
GitHub Actions workflow (benchmark.yml) runs benchmarks on:
Pull requests (compare against main)
Nightly builds (detect gradual regressions)
Change Action
< 5% Normal variance, no action
5-10% Flag for review
10-20% Investigate root cause
> 20% Block merge, fix required
Test coverage is measured using cargo-llvm-cov:
# Generate coverage report
cargo llvm-cov --workspace --html
# View report
open target/llvm-cov/html/index.html
Tier Crates Target Actual
Tier 1 (Critical)ecs, core, ai, render 80% 75.3%
Tier 2 (Important)physics, nav, gameplay 75% 72.6%
Tier 3 (Supporting)audio, scene, terrain 70% 71.8%
Tier 4 (Specialized)fluids, llm, prompts 65% 71.5%
Crate Coverage Status
astraweave-ecs 83.2% ✅
astraweave-core 79.1% ✅
astraweave-ai 71.3% ✅
astraweave-render 67.4% ✅
astraweave-physics 76.8% ✅
astraweave-fluids 94.2% ✅ A+
astraweave-nav 72.1% ✅
astraweave-gameplay 68.9% ✅
astraweave-terrain 71.5% ✅
astraweave-audio 69.2% ✅
astraweave-scene 74.6% ✅
astraweave-llm 58.3% ⚠️ Beta
# Verify ECS benchmarks match documentation
cargo bench -p astraweave-ecs -- entity_spawn/empty/10000
# Expected: ~645µs (±10%)
Clone repository at documented commit
Run ./scripts/benchmark_odyssey.ps1
Compare benchmark_results/*/ against documented values
Variance > 20% indicates environment difference
This guide covers performance optimization techniques for AstraWeave games.
Never optimize without profiling.
Before optimizing:
Run benchmarks to establish baseline
Profile with Tracy or cargo flamegraph
Identify actual bottlenecks
Optimize the measured slow path
Only parallelize work that benefits from it:
Sequential overhead limits speedup
0.15-22.4% of work is parallelizable in typical games
Maximum theoretical speedup: ~1.24× in many cases
#![allow(unused)]
fn main() {
// ❌ Slow: Scattered access
for entity in entities {
let pos = world.get_mut::<Position>(entity);
pos.x += 1.0;
}
// ✅ Fast: Batch access (3-5× faster)
let mut positions: Vec<_> = world.query::<&mut Position>().iter_mut().collect();
for pos in &mut positions {
pos.x += 1.0;
}
}
Why : Archetype lookup is O(log n) per access. Batching amortizes this cost.
Keep frequently-accessed components small
Group components that are accessed together
Use u32 over u64 when possible (cache efficiency)
#![allow(unused)]
fn main() {
// ❌ Slow: One-by-one spawning
for _ in 0..1000 {
world.spawn((Position::default(), Velocity::default()));
}
// ✅ Fast: Batch spawning
world.spawn_batch((0..1000).map(|_| (Position::default(), Velocity::default())));
}
GOAP cache hit: 9.8 ns vs cache miss: 286 ns (29× faster)
#![allow(unused)]
fn main() {
// Enable GOAP caching
let planner = GoapPlanner::new()
.with_cache_size(1000) // Cache 1000 plans
.with_cache_ttl(Duration::from_secs(5));
}
#![allow(unused)]
fn main() {
// Update 1/10th of agents per frame (10-frame rotation)
for (i, agent) in agents.iter_mut().enumerate() {
if i % 10 == frame_count % 10 {
agent.update(&snapshot);
}
}
}
#![allow(unused)]
fn main() {
// Reduce LLM request frequency for better performance
let arbiter = AIArbiter::new(executor, goap, bt)
.with_llm_cooldown(15.0); // 15 seconds between requests
}
AstraWeave’s spatial hash reduces collision checks by 99.96%:
#![allow(unused)]
fn main() {
// Automatic with default physics setup
let physics = PhysicsWorld::new()
.with_spatial_hash(cell_size: 2.0); // Tune cell size to object density
}
Tuning : Cell size should be ~2× average object radius.
#![allow(unused)]
fn main() {
// Batch physics updates (47µs for 100 bodies)
physics.step_batch(&mut bodies, dt);
}
#![allow(unused)]
fn main() {
// Enable sleeping for static objects
body.enable_sleeping(linear_threshold: 0.01, angular_threshold: 0.01);
}
Frustum check: 889-915 ps (essentially free)
#![allow(unused)]
fn main() {
// Automatic in renderer
renderer.enable_frustum_culling(true);
}
#![allow(unused)]
fn main() {
// Configure LOD distances
mesh.set_lod_distances(&[10.0, 50.0, 100.0, 500.0]);
}
Instance overhead: 1.43-1.52 ns per calculation
#![allow(unused)]
fn main() {
// Batch identical meshes
let instances = entities
.filter(|e| e.mesh_id == mesh_id)
.map(|e| e.transform)
.collect();
renderer.draw_instanced(mesh, &instances);
}
Operation SparseSet BTreeMap Winner
Lookup (1000) 1.56 ns 59 µs 37× SparseSet
Insert (1000) 9.9 ns 129 ns 13× SparseSet
AstraWeave uses SparseSet for entity storage—architecture decision validated.
#![allow(unused)]
fn main() {
// Reuse allocations
let mut pool = ComponentPool::<Particle>::new(10000);
for _ in 0..10000 {
let particle = pool.allocate();
// Use particle...
pool.deallocate(particle);
}
}
#![allow(unused)]
fn main() {
// Add Tracy spans to hot paths
#[cfg(feature = "profiling")]
astraweave_profiling::span!("AI::Update");
}
# Profile specific benchmark
cargo bench -p astraweave-ecs -- entity_spawn/empty/10000 --profile-time 10
# Generate flamegraph
cargo flamegraph -- --bench entity_bench
#![allow(unused)]
fn main() {
// Log frame timing
let start = Instant::now();
// ... game loop ...
let frame_time = start.elapsed();
if frame_time > Duration::from_millis(16) {
warn!("Frame budget exceeded: {:?}", frame_time);
}
}
Problem : Adding Rayon to small workloads
Why : Thread pool overhead (~50-100 µs) exceeds benefit for <5 ms work
Solution : Only parallelize workloads >5 ms
Problem : Vec::new() in per-frame code
Solution : Pre-allocate and reuse
#![allow(unused)]
fn main() {
// ❌ Bad: Allocates every frame
fn update(&mut self) {
let temp = Vec::new(); // Allocation!
// ...
}
// ✅ Good: Reuse allocation
fn update(&mut self) {
self.temp_buffer.clear(); // No allocation
// ...
}
}
Problem : Testing performance in debug mode
Why : Debug can be 10-100× slower
Solution : Always benchmark with --release
cargo bench -p astraweave-ecs # Automatically uses release
This document defines frame time allocations for AstraWeave games targeting 60 FPS.
Metric Value Notes
Target FPS 60 Standard for action games
Frame Budget 16.67 ms 1000ms ÷ 60
Headroom Target 20% 3.33 ms reserved for spikes
Available Budget 13.34 ms 80% of frame
System Budget (ms) Percentage Notes
Game Logic 2.0 12% ECS, AI, gameplay
Physics 2.5 15% Simulation, collision
Rendering 6.0 36% Draw calls, GPU sync
Audio 0.5 3% Spatial audio, mixing
Networking 1.0 6% Sync, prediction
UI 0.5 3% HUD, menus
I/O 0.5 3% Streaming, saves
Headroom 3.67 22% Spike absorption
Total 16.67 100% —
Frame Budget: 16.67ms
├── Game Logic ████░░░░░░░░░░░░░░░░ 2.0ms (12%)
├── Physics █████░░░░░░░░░░░░░░░ 2.5ms (15%)
├── Rendering ████████████░░░░░░░░ 6.0ms (36%)
├── Audio █░░░░░░░░░░░░░░░░░░░ 0.5ms (3%)
├── Networking ██░░░░░░░░░░░░░░░░░░ 1.0ms (6%)
├── UI █░░░░░░░░░░░░░░░░░░░ 0.5ms (3%)
├── I/O █░░░░░░░░░░░░░░░░░░░ 0.5ms (3%)
└── Headroom ███████░░░░░░░░░░░░░ 3.67ms (22%)
Component Budget (µs) Notes
ECS core (1k entities) 85 0.51% of frame
AI updates (500 agents) 471 GOAP + arbiter
Gameplay systems 500 Combat, quests, dialogue
Script execution 300 Rhai scripts
Event processing 200 Input, triggers
Total 1,556 22% headroom within budget
Entity Count ECS Budget AI Agents Feasibility
1,000 85 µs 500 ✅ Comfortable
5,000 529 µs 1,000 ✅ Within budget
10,000 1 ms 2,000 ⚠️ Near limit
20,000+ >2 ms — ❌ Consider 30 FPS
Component Budget (µs) Notes
Rigid body simulation 500 100 bodies
Character controllers 200 Player + NPCs
Collision detection 800 Spatial hash accelerated
Raycasts 300 AI, weapons, cameras
Triggers/sensors 200 Zone detection
Total 2,000 20% headroom
With spatial hashing (99.96% check reduction):
Objects Naive With Hash Savings
100 4,950 checks 2 checks 99.96%
1,000 499,500 checks ~20 checks 99.996%
10,000 49.9M checks ~200 checks 99.9996%
Stage Budget (ms) Notes
Culling 0.5 Frustum, occlusion
Shadow passes 1.0 CSM, spot shadows
G-Buffer 1.5 Deferred geometry
Lighting 1.0 Clustered, IBL
Post-processing 1.0 Bloom, SSAO, TAA
UI overlay 0.5 HUD, debug
GPU sync 0.5 Fence waiting
Total 6.0 —
Category Calls Notes
Opaque geometry 500 With instancing
Transparent 100 Sorted, no batching
Shadows 200 Per cascade
UI 50 Batched
Total 850 Target maximum
Component Budget (µs) Notes
Voice mixing 200 4-bus mixer
Spatial positioning 150 3D audio
DSP effects 100 Reverb, filters
Stream decode 50 Music, ambient
Total 500 —
#![allow(unused)]
fn main() {
use std::time::Instant;
struct FrameBudget {
logic_budget: Duration,
physics_budget: Duration,
render_budget: Duration,
}
impl FrameBudget {
fn check(&self, phase: &str, elapsed: Duration, budget: Duration) {
if elapsed > budget {
warn!(
"{} exceeded budget: {:?} > {:?} ({}%)",
phase,
elapsed,
budget,
(elapsed.as_micros() * 100 / budget.as_micros())
);
}
}
}
}
#![allow(unused)]
fn main() {
#[cfg(feature = "profiling")]
{
astraweave_profiling::zone!("GameLogic");
// ... game logic ...
}
}
For complex simulations or lower-end hardware:
Metric Value
Frame Budget 33.33 ms
Headroom 6.67 ms (20%)
Available 26.66 ms
When to use 30 FPS :
Open worlds with >20k entities
Complex physics simulations
Lower-end hardware targets
Cinematic experiences
Violation Duration Response
Minor 1-2 frames Log, continue
Moderate 3-10 frames Reduce quality
Severe >10 frames Emergency LOD
#![allow(unused)]
fn main() {
// Reduce quality on budget overrun
if frame_time > Duration::from_millis(18) {
quality.reduce_shadow_resolution();
}
if frame_time > Duration::from_millis(20) {
quality.reduce_draw_distance();
}
if frame_time > Duration::from_millis(25) {
quality.disable_post_processing();
}
}
Reference Implementation for AstraWeave AI-Native Game Engine
Veilweaver is a complete AI-native Action RPG that serves as AstraWeave’s flagship reference implementation. It demonstrates the full capabilities of the engine in a production game context.
Veilweaver showcases:
AI Companions with persistent memory and adaptive behavior
Adaptive Boss Battles that learn from player strategies
Dynamic World Events driven by AI directors
Emergent Narrative through AI-orchestrated storytelling
Procedural Content integrated with hand-crafted design
Veilweaver is developed in a separate repository and is currently in active development.
The Veilweaver documentation is being migrated to this documentation site. For the latest information:
GitHub Repository : Games-VEILWEAVER
Design Documents : Located in docs/archive/projects/veilweaver/
This page is an engine-facing reference overview, not a canon authority. Canon narrative, geography, and worldbuilding for the current workspace live in docs/Veilweaver/.
Veilweaver’s companions use AstraWeave’s full AI stack:
Perception Bus : Companions observe the game world in real-time
GOAP Planning : Goal-oriented action planning for complex behaviors
Memory System : Companions remember interactions and adapt
LLM Integration : Natural language dialogue and reasoning
The signature gameplay mechanic demonstrates:
Tool Sandbox : AI-validated player abilities
Deterministic Simulation : Consistent weaving effects
Physics Integration : Weave-affected environments
Three prototype biome slices showcase terrain systems. These are reference-implementation labels for the current vertical slice, not authoritative canon place names:
Loomspire Sanctum - Tutorial and hub area
Echo Grove - Forest exploration zone
Fractured Cliffs - Vertical traversal challenges
# Clone the Veilweaver repository
git clone https://github.com/lazyxeon/Games-VEILWEAVER.git
# Build and run
cd Games-VEILWEAVER
cargo run --release
For detailed Veilweaver documentation, see the Games-VEILWEAVER repository :
Game mechanics guide
AI integration deep-dive
World design principles
Asset creation workflow
Modding support guide
Thank you for your interest in contributing to AstraWeave! This guide will help you get started with contributing to the project.
We are committed to providing a welcoming and inclusive environment for all contributors. Please be respectful and constructive in all interactions.
Before you start contributing, please:
Read the Building from Source guide
Familiarize yourself with the Code Style Guide
Review the Testing Guide
Check existing Issues and Pull Requests
When reporting bugs, please include:
Clear title : Describe the issue concisely
Environment : OS, Rust version, GPU model
Steps to reproduce : Detailed steps to trigger the bug
Expected behavior : What should happen
Actual behavior : What actually happens
Logs : Relevant error messages or stack traces
**Environment:**
- OS: Windows 11
- Rust: 1.75.0
- GPU: NVIDIA RTX 4080
**Steps to Reproduce:**
1. Create a new companion with perception
2. Add emotion system
3. Run the simulation
**Expected:** Companion should respond to stimuli
**Actual:** Panic in emotion processing thread
**Logs:**
\`\`\`
thread 'emotion' panicked at 'index out of bounds'
\`\`\`
For feature requests, please:
Check if the feature already exists or is planned
Describe the use case and problem it solves
Provide examples of how it would work
Consider implementation complexity and alternatives
Fork the repository and create a new branch:
git checkout -b feature/my-awesome-feature
Make your changes following our coding standards
Write or update tests to cover your changes
Run the full test suite :
cargo test --all-features
cargo clippy --all-targets --all-features
cargo fmt --check
Update documentation if needed
Commit with clear messages :
git commit -m "feat(ai): Add emotion blending system"
Push and create a Pull Request :
git push origin feature/my-awesome-feature
Keep PRs focused on a single feature or fix. Large PRs are harder to review and merge.
Rust 1.75.0 or later
Git
A supported GPU with Vulkan/DirectX 12/Metal
# Clone the repository
git clone https://github.com/verdentlabs/astraweave.git
cd astraweave
# Build the project
cargo build
# Run tests
cargo test
# Run examples
cargo run --example basic_companion
# Create a new branch
git checkout -b feature/my-feature
# Make changes and test frequently
cargo test
# Run clippy for linting
cargo clippy --all-targets --all-features
# Format code
cargo fmt
# Commit changes
git commit -m "feat: Add feature description"
Clarity over cleverness : Write code that is easy to understand
Performance matters : AstraWeave is a high-performance engine
Safety first : Prefer safe abstractions over unsafe code
Documentation : Document all public APIs
#![allow(unused)]
fn main() {
// Good: Clear naming and documentation
/// Calculates emotion intensity based on stimuli strength.
///
/// # Arguments
/// * `stimulus` - The input stimulus value (0.0 to 1.0)
/// * `sensitivity` - Sensitivity multiplier (default: 1.0)
///
/// # Returns
/// Emotion intensity clamped to [0.0, 1.0]
pub fn calculate_emotion_intensity(stimulus: f32, sensitivity: f32) -> f32 {
(stimulus * sensitivity).clamp(0.0, 1.0)
}
// Bad: Unclear naming and no documentation
pub fn calc(s: f32, m: f32) -> f32 {
(s * m).min(1.0).max(0.0)
}
}
Types : PascalCase (e.g., EmotionSystem, CompanionState)
Functions : snake_case (e.g., process_emotion, update_behavior)
Constants : SCREAMING_SNAKE_CASE (e.g., MAX_COMPANIONS, DEFAULT_TICK_RATE)
Modules : snake_case (e.g., emotion_system, companion_ai)
#![allow(unused)]
fn main() {
// Good: Use Result for recoverable errors
pub fn load_companion_config(path: &Path) -> Result<CompanionConfig, ConfigError> {
let contents = std::fs::read_to_string(path)
.map_err(|e| ConfigError::FileRead(path.to_path_buf(), e))?;
serde_json::from_str(&contents)
.map_err(|e| ConfigError::Parse(e))
}
// Good: Use panic only for programming errors
pub fn get_emotion(&self, index: usize) -> &Emotion {
assert!(index < self.emotions.len(), "Emotion index out of bounds");
&self.emotions[index]
}
}
All contributions must include appropriate tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_emotion_intensity_clamping() {
assert_eq!(calculate_emotion_intensity(0.5, 1.0), 0.5);
assert_eq!(calculate_emotion_intensity(1.5, 1.0), 1.0);
assert_eq!(calculate_emotion_intensity(-0.5, 1.0), 0.0);
}
#[test]
fn test_emotion_blending() {
let joy = Emotion::new("joy", 0.8);
let calm = Emotion::new("calm", 0.6);
let blended = blend_emotions(&joy, &calm, 0.5);
assert_eq!(blended.intensity, 0.7);
}
}
}
Place integration tests in tests/ directory:
#![allow(unused)]
fn main() {
// tests/companion_integration.rs
use astraweave_ai::*;
#[test]
fn test_companion_lifecycle() {
let mut companion = CompanionBuilder::new()
.with_perception()
.with_emotion()
.build();
companion.update(0.016);
assert!(companion.is_active());
}
}
For performance-critical code, add benchmarks:
#![allow(unused)]
fn main() {
use criterion::{criterion_group, criterion_main, Criterion, black_box};
fn benchmark_emotion_processing(c: &mut Criterion) {
c.bench_function("process_100_emotions", |b| {
let mut system = EmotionSystem::new();
b.iter(|| {
for _ in 0..100 {
system.process(black_box(0.016));
}
});
});
}
criterion_group!(benches, benchmark_emotion_processing);
criterion_main!(benches);
}
PRs with insufficient test coverage will not be merged.
All public items must have documentation:
#![allow(unused)]
fn main() {
/// Represents an AI companion with emotion and behavior systems.
///
/// A companion can perceive its environment, process emotions, and
/// exhibit behaviors based on its internal state.
///
/// # Examples
///
/// ```
/// use astraweave_ai::Companion;
///
/// let companion = Companion::new("Buddy");
/// companion.update(0.016);
/// ```
pub struct Companion {
// ...
}
}
Add module-level documentation in lib.rs or mod.rs:
#![allow(unused)]
fn main() {
//! # Emotion System
//!
//! This module provides the emotion processing system for AI companions.
//! Emotions are processed based on stimuli and decay over time.
//!
//! ## Example
//!
//! ```
//! use astraweave_ai::emotion::EmotionSystem;
//!
//! let mut system = EmotionSystem::new();
//! system.add_emotion("joy", 0.8);
//! system.update(0.016);
//! ```
}
Update CHANGELOG.md for significant changes:
## [Unreleased]
### Added
- Emotion blending system for smoother transitions
- New `CompanionBuilder` for easier companion creation
### Changed
- Improved perception system performance by 30%
### Fixed
- Fixed emotion decay rate calculation
Correctness : Does the code work as intended?
Tests : Are there adequate tests?
Documentation : Is the code well-documented?
Style : Does it follow our coding standards?
Performance : Are there any performance concerns?
Safety : Are there any unsafe patterns?
Initial review: Within 48 hours
Follow-up reviews: Within 24 hours
Merging: After approval from at least one maintainer
Respond to all review comments, even if just to acknowledge them.
Make requested changes in new commits
Don’t force-push during review (makes tracking changes hard)
Mark conversations as resolved when addressed
Ask questions if feedback is unclear
All CI checks must pass
At least one maintainer approval required
No unresolved review comments
Up-to-date with the main branch
Watch the repository for updates
Follow our blog
Subscribe to release notifications
Thank you for contributing to AstraWeave! Your efforts help make AI-native gaming better for everyone.
This guide covers how to build AstraWeave from source on all supported platforms.
AstraWeave requires Rust 1.75.0 or later.
# Install Rust via rustup
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Or on Windows, download from: https://rustup.rs/
# Verify installation
rustc --version
cargo --version
Keep Rust updated for the latest performance improvements and bug fixes:
\`\`\`bash
rustup update
\`\`\`
# Linux (Debian/Ubuntu)
sudo apt install git
# macOS
brew install git
# Windows
# Download from https://git-scm.com/
AstraWeave requires a modern GPU with support for:
Vulkan 1.2+ (Linux, Windows)
DirectX 12 (Windows)
Metal (macOS)
Supported GPUs:
NVIDIA: GTX 1060 or newer
AMD: RX 580 or newer
Intel: Arc series or newer integrated graphics
Older GPUs may work but are not officially supported and may have reduced performance.
Visual Studio Build Tools (required for linking):
Vulkan SDK (optional, for Vulkan backend):
Download from LunarG
Install with default options
# Clone the repository
git clone https://github.com/verdentlabs/astraweave.git
cd astraweave
# Build (release mode recommended)
cargo build --release
# Run tests
cargo test --release
# Run an example
cargo run --release --example basic_companion
Windows Defender may flag the first build as suspicious. This is normal for newly compiled executables.
Long Path Support : Enable long paths if you encounter path length errors:
# Run as Administrator
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" `
-Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force
Debian/Ubuntu :
sudo apt update
sudo apt install -y \
build-essential \
pkg-config \
libx11-dev \
libxcursor-dev \
libxrandr-dev \
libxi-dev \
libasound2-dev \
libudev-dev \
vulkan-tools \
libvulkan-dev
Fedora/RHEL :
sudo dnf install -y \
gcc gcc-c++ \
pkg-config \
libX11-devel \
libXcursor-devel \
libXrandr-devel \
libXi-devel \
alsa-lib-devel \
systemd-devel \
vulkan-tools \
vulkan-loader-devel
Arch Linux :
sudo pacman -S --needed \
base-devel \
libx11 \
libxcursor \
libxrandr \
libxi \
alsa-lib \
vulkan-tools \
vulkan-headers
# Clone the repository
git clone https://github.com/verdentlabs/astraweave.git
cd astraweave
# Build
cargo build --release
# Run tests
cargo test --release
# Run an example
cargo run --release --example basic_companion
NVIDIA :
# Ubuntu
sudo ubuntu-drivers autoinstall
# Or manually install latest driver
sudo apt install nvidia-driver-535
AMD :
# Mesa drivers (usually pre-installed)
sudo apt install mesa-vulkan-drivers
Intel :
sudo apt install mesa-vulkan-drivers intel-media-va-driver
Verify Vulkan support:
vulkaninfo | grep "deviceName"
Xcode Command Line Tools :
xcode-select --install
Homebrew (recommended):
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Clone the repository
git clone https://github.com/verdentlabs/astraweave.git
cd astraweave
# Build
cargo build --release
# Run tests
cargo test --release
# Run an example
cargo run --release --example basic_companion
macOS uses Metal as the graphics backend. Ensure your system is macOS 10.15+ with Metal-compatible GPU.
AstraWeave fully supports Apple Silicon:
# Verify you're using the ARM toolchain
rustc --version --verbose | grep host
# Should show: aarch64-apple-darwin
# Build optimized for Apple Silicon
RUSTFLAGS="-C target-cpu=native" cargo build --release
Debug build (fast compilation, slow runtime):
cargo build
# Binary: target/debug/
Release build (slow compilation, fast runtime):
cargo build --release
# Binary: target/release/
Always use release builds for performance testing and gameplay. Debug builds can be 10-100x slower.
AstraWeave uses Cargo features for optional functionality:
# Build with all features
cargo build --all-features
# Build with specific features
cargo build --features "ai,physics,fluids"
# Build without default features
cargo build --no-default-features --features "minimal"
Available features:
ai - AI companion systems (default)
physics - Physics simulation
fluids - Fluid dynamics
editor - Level editor tools
networking - Multiplayer support
serialization - Save/load support
Custom profiles are defined in Cargo.toml:
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
[profile.dev]
opt-level = 1
[profile.bench]
inherits = "release"
lto = "fat"
Build with specific profile:
cargo build --profile bench
# Install target
rustup target add x86_64-pc-windows-gnu
# Build for target
cargo build --target x86_64-pc-windows-gnu --release
Common targets:
x86_64-pc-windows-msvc - Windows (MSVC)
x86_64-unknown-linux-gnu - Linux
x86_64-apple-darwin - macOS (Intel)
aarch64-apple-darwin - macOS (Apple Silicon)
Windows : Install Visual Studio Build Tools
Linux :
sudo apt install build-essential
macOS :
xcode-select --install
Windows : Install Vulkan SDK
Linux :
sudo apt install libvulkan-dev
macOS : Not needed (uses Metal)
Reduce parallel compilation:
cargo build --release -j 2
Or increase swap space on Linux:
sudo fallocate -l 8G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
Clean the build cache:
cargo clean
cargo build --release
Use the faster linker:
Linux (lld):
sudo apt install lld
export RUSTFLAGS="-C link-arg=-fuse-ld=lld"
cargo build --release
macOS (zld):
brew install michaeleisel/zld/zld
export RUSTFLAGS="-C link-arg=-fuse-ld=/usr/local/bin/zld"
cargo build --release
Windows (lld-link, included with MSVC):
$env:RUSTFLAGS="-C link-arg=/DEBUG:NONE"
cargo build --release
Enable sccache for cached builds:
cargo install sccache
export RUSTC_WRAPPER=sccache
Verify release mode:
# Should show /release/ in path
cargo run --release -- --version
Check GPU usage:
# Windows
nvidia-smi
# Linux
watch -n 1 nvidia-smi
Enable verbose output:
cargo build --release --verbose
Check dependencies:
cargo tree
Update dependencies:
cargo update
After successfully building AstraWeave:
Run the test suite : See Testing Guide
Try the examples : cargo run --release --example <name>
Read the tutorials : Getting Started
Join the community : Discord
Congratulations! You've successfully built AstraWeave from source. You're ready to start developing AI-native games.
This guide covers testing practices for AstraWeave, including unit tests, integration tests, benchmarks, and coverage analysis.
# Run all tests
cargo test
# Run tests in release mode (faster for compute-heavy tests)
cargo test --release
# Run tests for a specific package
cargo test -p astraweave-ai
# Run tests with all features enabled
cargo test --all-features
# Show println! output from tests
cargo test -- --nocapture
# Show one test per line
cargo test -- --test-threads=1 --nocapture
# Run specific test
cargo test test_emotion_processing -- --exact
# Run tests matching a pattern
cargo test emotion
# Run only unit tests (exclude integration tests)
cargo test --lib
# Run only integration tests
cargo test --test '*'
# Run only documentation tests
cargo test --doc
Use `cargo test --help` and `cargo test -- --help` to see all available options.
Place unit tests in the same file as the code they test:
#![allow(unused)]
fn main() {
pub fn calculate_emotion_decay(initial: f32, decay_rate: f32, dt: f32) -> f32 {
initial * (1.0 - decay_rate * dt).max(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_emotion_decay_basic() {
let result = calculate_emotion_decay(1.0, 0.5, 0.1);
assert_eq!(result, 0.95);
}
#[test]
fn test_emotion_decay_never_negative() {
let result = calculate_emotion_decay(1.0, 2.0, 1.0);
assert_eq!(result, 0.0);
}
}
}
Use approximate comparisons for floating-point values:
#![allow(unused)]
fn main() {
#[test]
fn test_emotion_blending() {
let result = blend_emotions(0.8, 0.6, 0.5);
assert!((result - 0.7).abs() < 1e-6, "Expected ~0.7, got {}", result);
}
// Or use the approx crate
use approx::assert_relative_eq;
#[test]
fn test_emotion_blending_approx() {
let result = blend_emotions(0.8, 0.6, 0.5);
assert_relative_eq!(result, 0.7, epsilon = 1e-6);
}
}
Test error conditions explicitly:
#![allow(unused)]
fn main() {
#[test]
fn test_invalid_companion_id() {
let world = CompanionWorld::new();
let result = world.get_companion(CompanionId(999));
assert!(result.is_err());
assert!(matches!(result, Err(CompanionError::NotFound(_))));
}
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_panic_on_invalid_index() {
let emotions = vec![Emotion::new("joy", 0.8)];
let _ = emotions[10]; // Should panic
}
}
Use setup and teardown for resource management:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn setup_test_config() -> (CompanionConfig, PathBuf) {
let temp_dir = std::env::temp_dir().join("astraweave_test");
fs::create_dir_all(&temp_dir).unwrap();
let config_path = temp_dir.join("config.json");
let config = CompanionConfig::default();
(config, config_path)
}
fn teardown_test_config(path: &Path) {
let _ = fs::remove_file(path);
}
#[test]
fn test_config_save_load() {
let (config, path) = setup_test_config();
config.save(&path).unwrap();
let loaded = CompanionConfig::load(&path).unwrap();
assert_eq!(config, loaded);
teardown_test_config(&path);
}
}
}
Test multiple cases efficiently:
#![allow(unused)]
fn main() {
#[test]
fn test_emotion_clamping() {
let test_cases = vec![
(0.5, 0.5), // Normal case
(1.5, 1.0), // Above max
(-0.5, 0.0), // Below min
(0.0, 0.0), // Edge case: zero
(1.0, 1.0), // Edge case: max
];
for (input, expected) in test_cases {
let result = clamp_emotion(input);
assert_eq!(result, expected, "Failed for input {}", input);
}
}
}
For async functions, use tokio::test or similar:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
use tokio::test;
#[tokio::test]
async fn test_async_companion_update() {
let mut companion = Companion::new("TestBot");
companion.update_async(0.016).await;
assert!(companion.is_initialized());
}
#[tokio::test]
async fn test_concurrent_updates() {
let companion = Arc::new(Mutex::new(Companion::new("Concurrent")));
let handles: Vec<_> = (0..10)
.map(|_| {
let c = Arc::clone(&companion);
tokio::spawn(async move {
c.lock().await.update_async(0.016).await;
})
})
.collect();
for handle in handles {
handle.await.unwrap();
}
}
}
}
Integration tests live in the tests/ directory:
astraweave-ai/
├── src/
│ └── lib.rs
├── tests/
│ ├── companion_lifecycle.rs
│ ├── emotion_integration.rs
│ └── perception_behavior.rs
└── Cargo.toml
#![allow(unused)]
fn main() {
// tests/companion_lifecycle.rs
use astraweave_ai::*;
#[test]
fn test_complete_companion_lifecycle() {
// Creation
let mut companion = CompanionBuilder::new()
.with_name("IntegrationBot")
.with_perception()
.with_emotion()
.with_behavior()
.build();
assert!(!companion.is_initialized());
// Initialization
companion.initialize();
assert!(companion.is_initialized());
// Update cycle
for _ in 0..100 {
companion.update(0.016);
}
// Verify state
assert!(companion.get_emotion("joy").is_some());
assert!(companion.perception_active());
// Shutdown
companion.shutdown();
assert!(!companion.is_active());
}
#[test]
fn test_companion_persistence() {
let temp_path = std::env::temp_dir().join("companion_save.json");
// Create and save
let original = CompanionBuilder::new()
.with_name("Persistent")
.build();
original.save(&temp_path).unwrap();
// Load and verify
let loaded = Companion::load(&temp_path).unwrap();
assert_eq!(original.name(), loaded.name());
std::fs::remove_file(&temp_path).unwrap();
}
}
Create test utilities in tests/common/mod.rs:
#![allow(unused)]
fn main() {
// tests/common/mod.rs
use astraweave_ai::*;
pub fn create_test_companion() -> Companion {
CompanionBuilder::new()
.with_name("TestCompanion")
.with_perception()
.with_emotion()
.build()
}
pub fn create_test_world() -> CompanionWorld {
let mut world = CompanionWorld::new();
for i in 0..5 {
world.add_companion(create_test_companion());
}
world
}
pub fn assert_emotion_in_range(emotion: &Emotion, min: f32, max: f32) {
assert!(
emotion.intensity >= min && emotion.intensity <= max,
"Emotion {} out of range [{}, {}]",
emotion.intensity,
min,
max
);
}
}
#![allow(unused)]
fn main() {
// tests/world_simulation.rs
mod common;
use common::*;
#[test]
fn test_world_simulation() {
let mut world = create_test_world();
for _ in 0..100 {
world.update(0.016);
}
assert_eq!(world.companion_count(), 5);
}
}
Benchmarks use the criterion crate. Add to Cargo.toml:
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "emotion_benchmarks"
harness = false
#![allow(unused)]
fn main() {
// benches/emotion_benchmarks.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
use astraweave_ai::*;
fn benchmark_emotion_update(c: &mut Criterion) {
let mut system = EmotionSystem::new();
c.bench_function("emotion_update_single", |b| {
b.iter(|| {
system.update(black_box(0.016));
});
});
}
fn benchmark_emotion_blending(c: &mut Criterion) {
let mut group = c.benchmark_group("emotion_blending");
for count in [10, 100, 1000] {
group.bench_with_input(
BenchmarkId::from_parameter(count),
&count,
|b, &count| {
let emotions: Vec<_> = (0..count)
.map(|i| Emotion::new(&format!("emotion_{}", i), 0.5))
.collect();
b.iter(|| {
blend_emotion_array(black_box(&emotions))
});
},
);
}
group.finish();
}
fn benchmark_companion_update(c: &mut Criterion) {
let mut companion = CompanionBuilder::new()
.with_perception()
.with_emotion()
.with_behavior()
.build();
c.bench_function("companion_full_update", |b| {
b.iter(|| {
companion.update(black_box(0.016));
});
});
}
criterion_group!(
benches,
benchmark_emotion_update,
benchmark_emotion_blending,
benchmark_companion_update
);
criterion_main!(benches);
}
# Run all benchmarks
cargo bench
# Run specific benchmark
cargo bench emotion_update
# Save baseline for comparison
cargo bench -- --save-baseline main
# Compare against baseline
cargo bench -- --baseline main
# Generate detailed reports
cargo bench -- --verbose
Benchmark results are saved in `target/criterion/`. You can view HTML reports by opening `target/criterion/report/index.html`.
#![allow(unused)]
fn main() {
// Good: Use black_box to prevent optimization
use std::hint::black_box;
c.bench_function("optimized_safe", |b| {
b.iter(|| {
expensive_function(black_box(input))
});
});
// Good: Separate setup from measurement
c.bench_function("with_setup", |b| {
let data = create_large_dataset();
b.iter(|| {
process_dataset(black_box(&data))
});
});
// Good: Use iter_batched for setup per iteration
use criterion::BatchSize;
c.bench_function("setup_per_iter", |b| {
b.iter_batched(
|| create_test_data(),
|data| process_data(data),
BatchSize::SmallInput,
);
});
}
# Install tarpaulin (Linux only)
cargo install cargo-tarpaulin
# Or use llvm-cov (cross-platform)
cargo install cargo-llvm-cov
Using tarpaulin (Linux):
# Generate coverage report
cargo tarpaulin --all-features --workspace --timeout 300 --out Html
# View report
firefox tarpaulin-report.html
Using llvm-cov (cross-platform):
# Install llvm-tools
rustup component add llvm-tools-preview
# Generate coverage
cargo llvm-cov --all-features --workspace --html
# View report
open target/llvm-cov/html/index.html
# .github/workflows/coverage.yml
name: Coverage
on: [push, pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Install tarpaulin
run: cargo install cargo-tarpaulin
- name: Generate coverage
run: cargo tarpaulin --all-features --workspace --timeout 300 --out Xml
- name: Upload to codecov
uses: codecov/codecov-action@v3
with:
files: cobertura.xml
Overall : Aim for >80% code coverage
Critical paths : AI logic, physics should be >90%
Edge cases : All error paths should be tested
Public APIs : 100% coverage of public interfaces
High coverage doesn't guarantee quality tests. Focus on meaningful test cases, not just hitting lines.
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
// Group related tests
mod emotion_tests {
use super::*;
#[test]
fn test_creation() { /* ... */ }
#[test]
fn test_update() { /* ... */ }
}
mod perception_tests {
use super::*;
#[test]
fn test_stimulus_detection() { /* ... */ }
}
}
}
#![allow(unused)]
fn main() {
// Good: Descriptive names
#[test]
fn test_emotion_decay_clamps_to_zero() { }
#[test]
fn test_companion_initialization_fails_without_required_components() { }
// Bad: Unclear names
#[test]
fn test1() { }
#[test]
fn test_emotion() { }
}
#![allow(unused)]
fn main() {
#[test]
fn test_companion_emotion_response() {
// Arrange
let mut companion = create_test_companion();
let stimulus = Stimulus::new(StimulusType::Positive, 0.8);
// Act
companion.process_stimulus(stimulus);
companion.update(0.016);
// Assert
let joy = companion.get_emotion("joy").unwrap();
assert!(joy.intensity > 0.5);
}
}
#![allow(unused)]
fn main() {
// Good: Each test is independent
#[test]
fn test_a() {
let mut system = EmotionSystem::new();
system.add_emotion("joy", 0.5);
assert_eq!(system.emotion_count(), 1);
}
#[test]
fn test_b() {
let mut system = EmotionSystem::new(); // Fresh instance
system.add_emotion("calm", 0.8);
assert_eq!(system.emotion_count(), 1);
}
}
#![allow(unused)]
fn main() {
/// Tests that emotion decay never produces negative values,
/// even with extreme decay rates or large time deltas.
#[test]
fn test_emotion_decay_bounds() {
let test_cases = vec![
(1.0, 10.0, 1.0), // Extreme decay
(0.5, 1.0, 100.0), // Large time delta
];
for (initial, rate, dt) in test_cases {
let result = calculate_emotion_decay(initial, rate, dt);
assert!(result >= 0.0, "Decay produced negative value: {}", result);
}
}
}
Well-tested code is reliable code. Invest time in comprehensive tests to catch bugs early and enable confident refactoring.
This guide defines the coding standards and style conventions for AstraWeave. Consistent code style improves readability and maintainability.
Idiomatic Rust : Write code that follows Rust idioms and patterns
Safety First : Prefer safe abstractions; document unsafe code thoroughly
Zero-Cost Abstractions : Don’t sacrifice performance for convenience
Explicit over Implicit : Make behavior clear and predictable
#![allow(unused)]
fn main() {
// Good: Clear structure with proper spacing
pub struct Companion {
id: CompanionId,
name: String,
emotion_system: EmotionSystem,
perception: PerceptionSystem,
}
impl Companion {
pub fn new(name: impl Into<String>) -> Self {
Self {
id: CompanionId::generate(),
name: name.into(),
emotion_system: EmotionSystem::default(),
perception: PerceptionSystem::default(),
}
}
pub fn update(&mut self, dt: f32) {
self.perception.update(dt);
self.emotion_system.update(dt);
}
}
// Bad: Cramped, hard to read
pub struct Companion{id:CompanionId,name:String}
impl Companion{pub fn new(n:String)->Self{Self{id:CompanionId::generate(),name:n}}}
}
Maximum line length: 100 characters
Break long lines at logical points
#![allow(unused)]
fn main() {
// Good: Broken at logical points
let companion = CompanionBuilder::new()
.with_name("Alice")
.with_perception()
.with_emotion()
.with_behavior()
.build();
// Bad: Too long
let companion = CompanionBuilder::new().with_name("Alice").with_perception().with_emotion().with_behavior().build();
}
Group and sort imports:
#![allow(unused)]
fn main() {
// 1. Standard library
use std::collections::HashMap;
use std::sync::Arc;
// 2. External crates (alphabetically)
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
// 3. Internal crates
use astraweave_ai::*;
use astraweave_physics::*;
// 4. Local modules
use crate::emotion::*;
use crate::perception::Stimulus;
// 5. Type aliases and trait imports
use super::CompanionId;
}
Use `cargo fmt` to automatically organize imports.
PascalCase for types, structs, enums, and traits:
#![allow(unused)]
fn main() {
// Good
pub struct CompanionState { }
pub enum EmotionType { }
pub trait BehaviorController { }
// Bad
pub struct companion_state { }
pub enum emotion_type { }
pub trait behavior_controller { }
}
snake_case for functions and methods:
#![allow(unused)]
fn main() {
// Good
pub fn calculate_emotion_intensity(stimulus: f32) -> f32 { }
pub fn process_visual_stimulus(&mut self, data: &[u8]) { }
// Bad
pub fn CalculateEmotionIntensity(stimulus: f32) -> f32 { }
pub fn ProcessVisualStimulus(&mut self, data: &[u8]) { }
}
snake_case for variables and parameters:
#![allow(unused)]
fn main() {
// Good
let companion_id = CompanionId::new();
let emotion_decay_rate = 0.95;
// Bad
let CompanionID = CompanionId::new();
let EmotionDecayRate = 0.95;
}
SCREAMING_SNAKE_CASE for constants:
#![allow(unused)]
fn main() {
// Good
pub const MAX_COMPANIONS: usize = 1000;
pub const DEFAULT_TICK_RATE: f32 = 60.0;
const PI: f32 = std::f32::consts::PI;
// Bad
pub const max_companions: usize = 1000;
pub const DefaultTickRate: f32 = 60.0;
}
snake_case for module names:
#![allow(unused)]
fn main() {
// Good
mod emotion_system;
mod companion_ai;
mod perception_processing;
// Bad
mod EmotionSystem;
mod CompanionAI;
mod PerceptionProcessing;
}
Treat acronyms as words in PascalCase:
#![allow(unused)]
fn main() {
// Good
struct HttpClient;
struct XmlParser;
struct AiCompanion;
// Bad (but acceptable for well-known 2-letter acronyms)
struct AICompanion; // Acceptable
struct XMLParser; // Avoid
// Definitely bad
struct HTTPClient;
}
Organize code logically by functionality:
astraweave-ai/
├── src/
│ ├── lib.rs // Public API exports
│ ├── companion/
│ │ ├── mod.rs // Module root
│ │ ├── builder.rs // CompanionBuilder
│ │ ├── state.rs // CompanionState
│ │ └── lifecycle.rs // Lifecycle management
│ ├── emotion/
│ │ ├── mod.rs
│ │ ├── system.rs // EmotionSystem
│ │ ├── types.rs // Emotion types
│ │ └── blending.rs // Emotion blending
│ └── perception/
│ ├── mod.rs
│ ├── visual.rs // Visual perception
│ └── auditory.rs // Auditory perception
Structure within a file:
#![allow(unused)]
fn main() {
// 1. Module documentation
//! # Emotion System
//!
//! This module implements the emotion processing system.
// 2. Imports (grouped as shown earlier)
use std::collections::HashMap;
use bevy::prelude::*;
// 3. Type definitions
pub struct EmotionSystem {
emotions: HashMap<String, Emotion>,
}
// 4. Implementation blocks
impl EmotionSystem {
pub fn new() -> Self {
Self {
emotions: HashMap::new(),
}
}
}
// 5. Trait implementations
impl Default for EmotionSystem {
fn default() -> Self {
Self::new()
}
}
// 6. Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_emotion_system_creation() {
let system = EmotionSystem::new();
assert!(system.emotions.is_empty());
}
}
}
Be intentional about visibility:
#![allow(unused)]
fn main() {
// Good: Clear public API
pub struct Companion {
// Public fields only when necessary
pub id: CompanionId,
// Private implementation details
emotion_system: EmotionSystem,
internal_state: State,
}
impl Companion {
// Public constructor
pub fn new(name: String) -> Self { }
// Public methods for API
pub fn update(&mut self, dt: f32) { }
// Private helper methods
fn process_internal_state(&mut self) { }
}
// Bad: Everything public
pub struct Companion {
pub id: CompanionId,
pub emotion_system: EmotionSystem,
pub internal_state: State,
}
}
#![allow(unused)]
fn main() {
//! # Emotion System
//!
//! This module provides the core emotion processing system for AI companions.
//! Emotions are represented as continuous values that decay over time and
//! blend based on stimuli.
//!
//! ## Example
//!
//! ```
//! use astraweave_ai::emotion::EmotionSystem;
//!
//! let mut system = EmotionSystem::new();
//! system.add_emotion("joy", 0.8);
//! system.update(0.016);
//! ```
}
#![allow(unused)]
fn main() {
/// Represents an AI companion with emotion and behavior systems.
///
/// A companion processes stimuli through its perception system,
/// updates its emotional state, and exhibits behaviors based on
/// its internal state and environment.
///
/// # Examples
///
/// ```
/// use astraweave_ai::Companion;
///
/// let mut companion = Companion::new("Buddy");
/// companion.update(0.016);
///
/// if let Some(emotion) = companion.get_emotion("joy") {
/// println!("Joy level: {}", emotion.intensity);
/// }
/// ```
///
/// # Performance
///
/// Companion updates are O(n) where n is the number of active
/// emotions and behaviors.
pub struct Companion {
// ...
}
}
#![allow(unused)]
fn main() {
/// Calculates the blended emotion intensity from two emotions.
///
/// Uses linear interpolation to blend two emotion intensities
/// based on a blend factor.
///
/// # Arguments
///
/// * `emotion_a` - First emotion intensity (0.0 to 1.0)
/// * `emotion_b` - Second emotion intensity (0.0 to 1.0)
/// * `blend_factor` - Blend weight (0.0 = all A, 1.0 = all B)
///
/// # Returns
///
/// Blended emotion intensity clamped to [0.0, 1.0]
///
/// # Examples
///
/// ```
/// use astraweave_ai::emotion::blend_emotions;
///
/// let joy = 0.8;
/// let calm = 0.6;
/// let blended = blend_emotions(joy, calm, 0.5);
/// assert_eq!(blended, 0.7);
/// ```
///
/// # Panics
///
/// Panics if `blend_factor` is not in range [0.0, 1.0] in debug builds.
pub fn blend_emotions(emotion_a: f32, emotion_b: f32, blend_factor: f32) -> f32 {
debug_assert!(
(0.0..=1.0).contains(&blend_factor),
"blend_factor must be in [0.0, 1.0]"
);
(emotion_a * (1.0 - blend_factor) + emotion_b * blend_factor).clamp(0.0, 1.0)
}
}
Use these sections in order:
Summary : One-line description
Detailed description : Multi-paragraph explanation
Arguments : Parameter descriptions
Returns : Return value description
Examples : Code examples
Errors : Possible errors (for Result)
Panics : Panic conditions
Safety : Safety invariants (for unsafe)
Performance : Performance characteristics
#![allow(unused)]
fn main() {
// Good: Use Result for expected errors
pub fn load_companion(path: &Path) -> Result<Companion, LoadError> {
let data = std::fs::read_to_string(path)
.map_err(|e| LoadError::FileRead(path.to_path_buf(), e))?;
serde_json::from_str(&data)
.map_err(LoadError::Parse)
}
// Bad: Panic on expected errors
pub fn load_companion(path: &Path) -> Companion {
let data = std::fs::read_to_string(path).unwrap(); // Don't do this
serde_json::from_str(&data).unwrap()
}
}
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CompanionError {
#[error("Companion not found: {0}")]
NotFound(CompanionId),
#[error("Invalid companion state: {0}")]
InvalidState(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error: {0}")]
Parse(#[from] serde_json::Error),
}
}
#![allow(unused)]
fn main() {
use anyhow::{Context, Result};
pub fn save_companion(&self, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(self)
.context("Failed to serialize companion")?;
std::fs::write(path, json)
.with_context(|| format!("Failed to write to {}", path.display()))?;
Ok(())
}
}
#![allow(unused)]
fn main() {
// Good: Iterator-based
fn sum_emotions(emotions: &[Emotion]) -> f32 {
emotions.iter().map(|e| e.intensity).sum()
}
// Less efficient: Index-based
fn sum_emotions_indexed(emotions: &[Emotion]) -> f32 {
let mut sum = 0.0;
for i in 0..emotions.len() {
sum += emotions[i].intensity;
}
sum
}
}
#![allow(unused)]
fn main() {
// Good: Reuse buffer
pub struct EmotionProcessor {
buffer: Vec<f32>,
}
impl EmotionProcessor {
pub fn process(&mut self, emotions: &[Emotion]) {
self.buffer.clear();
self.buffer.extend(emotions.iter().map(|e| e.intensity));
// Process buffer...
}
}
// Bad: Allocate every time
pub fn process(emotions: &[Emotion]) -> Vec<f32> {
emotions.iter().map(|e| e.intensity).collect() // New allocation
}
}
#![allow(unused)]
fn main() {
// Good: Borrow instead of clone
pub fn analyze_emotion(&self, emotion: &Emotion) -> Analysis {
Analysis {
intensity: emotion.intensity,
category: emotion.category.clone(), // Only clone when necessary
}
}
// Bad: Unnecessary clone
pub fn analyze_emotion(&self, emotion: Emotion) -> Analysis {
// Took ownership, forcing caller to clone
Analysis {
intensity: emotion.intensity,
category: emotion.category,
}
}
}
#![allow(unused)]
fn main() {
/// Finds a companion by ID.
///
/// # Performance
///
/// O(1) average case using HashMap lookup.
pub fn find_companion(&self, id: CompanionId) -> Option<&Companion> {
self.companions.get(&id)
}
/// Sorts companions by emotion intensity.
///
/// # Performance
///
/// O(n log n) where n is the number of companions.
/// Consider using `find_max_emotion` for single maximum lookup.
pub fn sort_by_emotion(&mut self, emotion_name: &str) {
self.companions.sort_by(|a, b| {
a.get_emotion(emotion_name)
.cmp(&b.get_emotion(emotion_name))
});
}
}
# Run clippy with all features
cargo clippy --all-targets --all-features
# Fix clippy warnings automatically (where possible)
cargo clippy --fix --all-targets --all-features
# Deny all warnings (for CI)
cargo clippy --all-targets --all-features -- -D warnings
Enable project-wide lints in lib.rs:
#![allow(unused)]
#![warn(
fn main() {
clippy::all,
clippy::pedantic,
clippy::cargo,
missing_docs,
rust_2018_idioms,
)]
// Allow specific lints when justified
#![allow(
clippy::module_name_repetitions, // CompanionBuilder in companion module is fine
clippy::must_use_candidate, // Not all pure functions need #[must_use]
)]
}
# Format all code
cargo fmt
# Check formatting without applying
cargo fmt -- --check
# Format with custom config
cargo fmt -- --config max_width=100
Create rustfmt.toml:
max_width = 100
hard_tabs = false
tab_spaces = 4
newline_style = "Unix"
use_small_heuristics = "Default"
reorder_imports = true
reorder_modules = true
remove_nested_parens = true
edition = "2021"
Create .git/hooks/pre-commit:
#!/bin/sh
# Run formatter
cargo fmt -- --check
if [ $? -ne 0 ]; then
echo "Code is not formatted. Run 'cargo fmt' first."
exit 1
fi
# Run clippy
cargo clippy --all-targets --all-features -- -D warnings
if [ $? -ne 0 ]; then
echo "Clippy found issues. Fix them before committing."
exit 1
fi
exit 0
Consistent style makes code easier to read, review, and maintain. Use automated tools to enforce standards.
This guide walks you through the process of contributing new features to AstraWeave, from initial proposal through implementation, testing, and documentation.
graph LR
subgraph Proposal["1. Proposal"]
RFC[RFC Discussion] --> DESIGN[Design Doc]
DESIGN --> APPROVAL[Team Review]
end
subgraph Implementation["2. Implementation"]
APPROVAL --> BRANCH[Feature Branch]
BRANCH --> CODE[Code + Tests]
CODE --> DOCS[Documentation]
end
subgraph Review["3. Review"]
DOCS --> PR[Pull Request]
PR --> CI[CI Validation]
CI --> REVIEW[Code Review]
REVIEW --> MERGE[Merge]
end
Before implementing a significant feature, create a discussion or RFC:
## Feature: [Name]
### Summary
One paragraph describing the feature.
### Motivation
- Why is this needed?
- What problem does it solve?
- Who benefits?
### Design Overview
- High-level approach
- Key components affected
- API surface changes
### Alternatives Considered
- Option A: ...
- Option B: ...
- Why chosen approach is better
### Breaking Changes
- None / List any breaking changes
### Implementation Plan
1. Step 1
2. Step 2
3. ...
For complex features, create a design document in docs/design/:
# Design: [Feature Name]
## Goals
- Primary objective
- Secondary objectives
## Non-Goals
- Explicitly out of scope
## Architecture
[Mermaid diagram or description]
## API Design
```rust
// Public API examples
How existing code adapts to the new feature.
How the feature will be tested.
Estimated implementation phases.
## Phase 2: Implementation
### Branch Setup
```bash
git checkout main
git pull origin main
git checkout -b feature/my-feature-name
Understand where your feature belongs:
astraweave/
├── astraweave-ecs/ # Core ECS (rare modifications)
├── astraweave-ai/ # AI systems
├── astraweave-render/ # Rendering
├── astraweave-physics/ # Physics
├── astraweave-gameplay/ # Gameplay systems
├── astraweave-*/ # Other subsystems
└── examples/ # Feature examples
For substantial features, create a new crate:
cargo new --lib astraweave-myfeature
Cargo.toml template:
[package]
name = "astraweave-myfeature"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
description = "Brief description"
repository = "https://github.com/astraweave/astraweave"
keywords = ["gamedev", "astraweave"]
[dependencies]
astraweave-ecs = { path = "../astraweave-ecs" }
[dev-dependencies]
criterion = "0.5"
[features]
default = []
[[bench]]
name = "my_benchmark"
harness = false
Update root Cargo.toml:
[workspace]
members = [
# ... existing crates
"astraweave-myfeature",
]
Follow the standard module layout:
astraweave-myfeature/
├── src/
│ ├── lib.rs # Public API, re-exports
│ ├── prelude.rs # Common imports
│ ├── component.rs # ECS components
│ ├── system.rs # ECS systems
│ ├── resource.rs # ECS resources
│ └── internal/ # Private implementation
│ └── mod.rs
├── tests/
│ └── integration.rs # Integration tests
├── benches/
│ └── benchmark.rs # Performance benchmarks
└── Cargo.toml
#![allow(unused)]
fn main() {
pub mod prelude {
pub use crate::MyComponent;
pub use crate::MyResource;
pub use crate::my_system;
}
#[derive(Component, Debug, Clone)]
pub struct MyComponent {
pub visible_field: f32,
}
impl MyComponent {
pub fn new(value: f32) -> Self {
Self { visible_field: value }
}
pub fn with_option(mut self, option: bool) -> Self {
self
}
}
#[derive(Resource, Default)]
pub struct MyResource {
config: MyConfig,
}
pub fn my_system(
query: Query<&MyComponent>,
resource: Res<MyResource>,
) {
for component in query.iter() {
// Implementation
}
}
}
Define clear error types:
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum MyFeatureError {
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
#[error("Resource not found: {0}")]
NotFound(String),
#[error("Operation failed: {0}")]
OperationFailed(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, MyFeatureError>;
}
Use feature flags for optional functionality:
[features]
default = ["standard"]
standard = []
advanced = ["dep:optional-crate"]
serde = ["dep:serde"]
[dependencies]
optional-crate = { version = "1.0", optional = true }
serde = { version = "1.0", features = ["derive"], optional = true }
#![allow(unused)]
fn main() {
#[cfg(feature = "advanced")]
pub mod advanced {
pub fn advanced_function() {
// Only available with "advanced" feature
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for MyComponent {
// Serialization implementation
}
}
Include tests alongside implementation:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_component_creation() {
let component = MyComponent::new(42.0);
assert_eq!(component.visible_field, 42.0);
}
#[test]
fn test_builder_pattern() {
let component = MyComponent::new(1.0)
.with_option(true);
// Assertions
}
#[test]
#[should_panic(expected = "Invalid value")]
fn test_invalid_input() {
MyComponent::new(-1.0);
}
}
}
Create tests/integration.rs:
#![allow(unused)]
fn main() {
use astraweave_ecs::prelude::*;
use astraweave_myfeature::prelude::*;
#[test]
fn test_system_integration() {
let mut world = World::new();
world.spawn(MyComponent::new(10.0));
world.insert_resource(MyResource::default());
let mut schedule = Schedule::default();
schedule.add_system(my_system);
schedule.run(&mut world);
// Verify results
}
#[test]
fn test_full_workflow() {
// Test complete feature workflow
}
}
Create benches/benchmark.rs:
#![allow(unused)]
fn main() {
use criterion::{criterion_group, criterion_main, Criterion, BenchmarkId};
use astraweave_myfeature::*;
fn benchmark_creation(c: &mut Criterion) {
c.bench_function("component_create", |b| {
b.iter(|| MyComponent::new(42.0))
});
}
fn benchmark_system(c: &mut Criterion) {
let mut group = c.benchmark_group("system_throughput");
for count in [100, 1000, 10000].iter() {
group.bench_with_input(
BenchmarkId::from_parameter(count),
count,
|b, &count| {
let mut world = setup_world(count);
let mut schedule = setup_schedule();
b.iter(|| schedule.run(&mut world));
},
);
}
group.finish();
}
criterion_group!(benches, benchmark_creation, benchmark_system);
criterion_main!(benches);
}
Run benchmarks:
cargo bench -p astraweave-myfeature
Document all public items:
#![allow(unused)]
fn main() {
/// A component that tracks feature state.
///
/// # Examples
///
/// ```rust
/// use astraweave_myfeature::MyComponent;
///
/// let component = MyComponent::new(42.0);
/// assert_eq!(component.visible_field, 42.0);
/// ```
///
/// # Panics
///
/// Panics if `value` is negative.
#[derive(Component)]
pub struct MyComponent {
/// The primary value tracked by this component.
pub visible_field: f32,
}
/// Creates a new component with the given value.
///
/// # Arguments
///
/// * `value` - The initial value (must be non-negative)
///
/// # Returns
///
/// A new `MyComponent` instance.
///
/// # Examples
///
/// ```rust
/// let component = MyComponent::new(10.0);
/// ```
impl MyComponent {
pub fn new(value: f32) -> Self {
assert!(value >= 0.0, "Invalid value");
Self { visible_field: value }
}
}
}
Add documentation to docs/src/:
# My Feature
Brief introduction explaining what the feature does.
## Quick Start
```rust
use astraweave_myfeature::prelude::*;
// Minimal example
Explain key concepts and terminology.
Step-by-step instructions.
More complex scenarios.
Available options and settings.
Tips for effective use.
### Update SUMMARY.md
Add your documentation to `docs/src/SUMMARY.md`:
```markdown
- [Core Systems](core-systems/index.md)
- [My Feature](core-systems/myfeature.md) # Add here
Before opening a PR:
cargo fmt --all
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all
cargo doc --no-deps --all-features
## Summary
Brief description of changes.
## Changes
- Added `MyComponent` for tracking X
- Implemented `my_system` for processing Y
- Added benchmarks for performance validation
## Testing
- [x] Unit tests added
- [x] Integration tests added
- [x] Benchmarks added
- [x] Manual testing completed
## Documentation
- [x] API docs complete
- [x] User guide added
- [x] SUMMARY.md updated
## Breaking Changes
None / List any breaking changes.
## Related Issues
Fixes #123
Related to #456
Your PR must pass:
Format check : cargo fmt --check
Lint check : cargo clippy
Test suite : cargo test --all
Doc generation : cargo doc
Benchmark regression : No significant slowdowns
Expect reviewers to check:
API design and ergonomics
Performance implications
Test coverage
Documentation quality
Breaking change management
1. **Start Small**: Begin with minimal viable feature, iterate
2. **Design for Extension**: Make it easy to add functionality later
3. **Follow Conventions**: Match existing code style and patterns
4. **Test Edge Cases**: Cover error conditions and boundaries
5. **Document Intent**: Explain why, not just what
- **Scope Creep**: Resist adding "just one more thing"
- **Missing Tests**: Every public function needs tests
- **Breaking Changes**: Avoid unless absolutely necessary
- **Performance Regression**: Always benchmark critical paths
Here’s a complete example of adding a health regeneration system:
#![allow(unused)]
fn main() {
// astraweave-gameplay/src/health_regen.rs
use astraweave_ecs::prelude::*;
/// Configuration for health regeneration.
#[derive(Resource)]
pub struct HealthRegenConfig {
/// Base regeneration rate (HP per second).
pub base_rate: f32,
/// Delay before regeneration starts after damage.
pub regen_delay: f32,
}
impl Default for HealthRegenConfig {
fn default() -> Self {
Self {
base_rate: 5.0,
regen_delay: 3.0,
}
}
}
/// Enables health regeneration on an entity.
#[derive(Component)]
pub struct HealthRegen {
/// Multiplier applied to base regeneration rate.
pub rate_multiplier: f32,
/// Time since last damage taken.
pub time_since_damage: f32,
}
impl Default for HealthRegen {
fn default() -> Self {
Self {
rate_multiplier: 1.0,
time_since_damage: 0.0,
}
}
}
/// System that regenerates health over time.
pub fn health_regen_system(
config: Res<HealthRegenConfig>,
time: Res<Time>,
mut query: Query<(&mut Health, &mut HealthRegen)>,
) {
let dt = time.delta_seconds();
for (mut health, mut regen) in query.iter_mut() {
regen.time_since_damage += dt;
if regen.time_since_damage >= config.regen_delay {
let regen_amount = config.base_rate * regen.rate_multiplier * dt;
health.current = (health.current + regen_amount).min(health.max);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_regen_after_delay() {
// Test implementation
}
}
}
This guide covers profiling, benchmarking, and optimization techniques for AstraWeave games and the engine itself.
AstraWeave targets consistent 60+ FPS gameplay with AI-native features. Key principles:
Measure First : Always profile before optimizing
Budget Time : Allocate frame time across systems
Batch Operations : Minimize per-entity overhead
Cache Strategically : Trade memory for speed where appropriate
Offload to Threads : Parallelize independent work
At 60 FPS, each frame has ~16.67ms. Recommended allocation:
System Budget Notes
Game Logic 2-3ms ECS systems, gameplay
AI 2-4ms Perception, planning, behaviors
Physics 2-3ms Collision, dynamics
Rendering 6-8ms Draw calls, GPU submission
Audio 0.5-1ms Mixing, spatial
Buffer 1-2ms Headroom for spikes
AstraWeave integrates with Tracy for real-time profiling:
[dependencies]
astraweave-profiling = { version = "0.1", features = ["tracy"] }
#![allow(unused)]
fn main() {
use astraweave_profiling::*;
fn my_system(query: Query<&MyComponent>) {
profile_scope!("my_system");
for component in query.iter() {
profile_scope!("process_entity");
// Work
}
}
}
Run with Tracy:
cargo run --release --features tracy
Enable the debug overlay:
#![allow(unused)]
fn main() {
use astraweave_profiling::prelude::*;
app.add_plugin(ProfilingPlugin::default());
}
Press F3 in-game to toggle the performance overlay showing:
Frame time graph
System timing breakdown
Memory usage
Draw call count
For detailed CPU analysis:
# Linux (perf)
perf record -g cargo run --release
perf report
# Windows (ETW)
cargo build --release
# Use Windows Performance Analyzer
# macOS (Instruments)
cargo instruments --release -t time
# NVIDIA Nsight
cargo run --release
# Attach Nsight Graphics
# RenderDoc
cargo run --release --features renderdoc
# Press F12 to capture frame
#![allow(unused)]
fn main() {
// Bad: Iterating all entities
fn slow_system(query: Query<&Transform>) {
for transform in query.iter() {
// Processes all entities with Transform
}
}
// Good: Filter to relevant entities
fn fast_system(
query: Query<&Transform, (With<Enemy>, Without<Dead>)>,
) {
for transform in query.iter() {
// Only active enemies
}
}
}
#![allow(unused)]
fn main() {
use rayon::prelude::*;
fn parallel_system(query: Query<&mut Transform>) {
query.par_iter_mut().for_each(|mut transform| {
// Thread-safe processing
transform.translation.y += 0.1;
});
}
}
#![allow(unused)]
fn main() {
fn efficient_update(
query: Query<&MyComponent, Changed<MyComponent>>,
) {
for component in query.iter() {
// Only processes recently changed entities
}
}
}
Group components that are commonly accessed together:
#![allow(unused)]
fn main() {
// Good: Components often queried together
#[derive(Bundle)]
struct EnemyBundle {
transform: Transform,
health: Health,
ai: AiAgent,
collider: Collider,
}
// Avoid: Rarely used components on common entities
struct RarelyUsedData { /* ... */ }
}
#![allow(unused)]
fn main() {
// Bad: Large component
#[derive(Component)]
struct LargeComponent {
data: [f32; 1000], // 4KB per entity
name: String,
}
// Good: Split into data and reference
#[derive(Component)]
struct SmallComponent {
data_handle: Handle<LargeData>, // 8 bytes
flags: u8,
}
}
#![allow(unused)]
fn main() {
use astraweave_ecs::pool::*;
#[derive(Resource)]
struct BulletPool {
pool: EntityPool<BulletBundle>,
}
impl BulletPool {
fn spawn(&mut self, commands: &mut Commands) -> Entity {
self.pool.get_or_spawn(commands, || BulletBundle::default())
}
fn despawn(&mut self, entity: Entity) {
self.pool.return_entity(entity);
}
}
}
For temporary allocations:
#![allow(unused)]
fn main() {
use bumpalo::Bump;
fn batch_process(entities: &[Entity]) {
let arena = Bump::new();
let temp_data: &mut [Vec3] = arena.alloc_slice_fill_default(entities.len());
// Work with temp_data
// Arena automatically freed at scope end
}
}
#![allow(unused)]
fn main() {
// Enable instanced rendering for repeated meshes
#[derive(Component)]
struct InstancedMesh {
mesh: Handle<Mesh>,
material: Handle<Material>,
instances: Vec<Transform>,
}
}
#![allow(unused)]
fn main() {
#[derive(Component)]
struct LodGroup {
distances: [f32; 3],
meshes: [Handle<Mesh>; 3],
}
fn lod_system(
camera: Query<&Transform, With<Camera>>,
mut lod_query: Query<(&Transform, &LodGroup, &mut Handle<Mesh>)>,
) {
let camera_pos = camera.single().translation;
for (transform, lod, mut mesh) in lod_query.iter_mut() {
let distance = transform.translation.distance(camera_pos);
let lod_level = if distance < lod.distances[0] { 0 }
else if distance < lod.distances[1] { 1 }
else { 2 };
*mesh = lod.meshes[lod_level].clone();
}
}
}
#![allow(unused)]
fn main() {
#[derive(Component)]
struct Visibility {
pub visible: bool,
pub render_layers: u32,
}
fn frustum_culling_system(
camera: Query<(&Camera, &Transform)>,
mut renderables: Query<(&Transform, &Aabb, &mut Visibility)>,
) {
let (camera, cam_transform) = camera.single();
let frustum = camera.compute_frustum(cam_transform);
for (transform, aabb, mut visibility) in renderables.iter_mut() {
let world_aabb = aabb.transformed(transform);
visibility.visible = frustum.intersects_aabb(&world_aabb);
}
}
}
#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct AiBudget {
pub max_ms_per_frame: f32,
pub agents_processed: usize,
}
fn budgeted_ai_system(
mut budget: ResMut<AiBudget>,
mut agents: Query<&mut AiAgent>,
time: Res<Time>,
) {
let start = std::time::Instant::now();
budget.agents_processed = 0;
for mut agent in agents.iter_mut() {
if start.elapsed().as_secs_f32() * 1000.0 > budget.max_ms_per_frame {
break;
}
agent.tick();
budget.agents_processed += 1;
}
}
}
#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct AiLod {
pub distance_from_player: f32,
pub update_frequency: u32,
pub frames_since_update: u32,
}
fn ai_lod_system(
player: Query<&Transform, With<Player>>,
mut ai_query: Query<(&Transform, &mut AiLod, &mut AiAgent)>,
) {
let player_pos = player.single().translation;
for (transform, mut lod, mut agent) in ai_query.iter_mut() {
lod.distance_from_player = transform.translation.distance(player_pos);
lod.update_frequency = match lod.distance_from_player {
d if d < 20.0 => 1, // Every frame
d if d < 50.0 => 2, // Every 2 frames
d if d < 100.0 => 5, // Every 5 frames
_ => 10, // Every 10 frames
};
lod.frames_since_update += 1;
if lod.frames_since_update >= lod.update_frequency {
lod.frames_since_update = 0;
agent.tick();
}
}
}
}
#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct LlmCache {
cache: LruCache<u64, String>,
hit_count: u64,
miss_count: u64,
}
impl LlmCache {
pub fn get_or_generate<F>(
&mut self,
prompt: &str,
generator: F,
) -> &str
where
F: FnOnce() -> String,
{
let hash = hash_prompt(prompt);
if !self.cache.contains(&hash) {
self.miss_count += 1;
let response = generator();
self.cache.put(hash, response);
} else {
self.hit_count += 1;
}
self.cache.get(&hash).unwrap()
}
}
}
#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct PhysicsConfig {
pub broad_phase: BroadPhaseType,
pub substeps: u32,
pub velocity_iterations: u32,
}
pub enum BroadPhaseType {
BruteForce, // < 100 entities
SpatialHash, // 100-1000 entities
BvhTree, // > 1000 entities
}
}
#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct RigidBody {
pub sleeping: bool,
pub sleep_threshold: f32,
pub sleep_timer: f32,
}
fn sleep_system(mut bodies: Query<(&Velocity, &mut RigidBody)>) {
for (velocity, mut body) in bodies.iter_mut() {
if velocity.linear.length_squared() < body.sleep_threshold {
body.sleep_timer += delta;
if body.sleep_timer > 0.5 {
body.sleeping = true;
}
} else {
body.sleep_timer = 0.0;
body.sleeping = false;
}
}
}
}
#![allow(unused)]
fn main() {
use criterion::{criterion_group, criterion_main, Criterion, BenchmarkId};
fn bench_ecs_iteration(c: &mut Criterion) {
let mut group = c.benchmark_group("ecs_iteration");
for entity_count in [1000, 10000, 100000] {
let world = create_world_with_entities(entity_count);
group.bench_with_input(
BenchmarkId::new("query", entity_count),
&world,
|b, world| {
b.iter(|| {
let mut count = 0;
for _ in world.query::<&Transform>().iter() {
count += 1;
}
count
});
},
);
}
group.finish();
}
criterion_group!(benches, bench_ecs_iteration);
criterion_main!(benches);
}
# .github/workflows/bench.yml
name: Benchmarks
on: [push, pull_request]
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run benchmarks
run: cargo bench --all -- --save-baseline new
- name: Compare with main
run: |
git fetch origin main
cargo bench --all -- --baseline main --save-baseline new
Symptoms : Occasional stutters, inconsistent frame times
Causes :
GC in scripting
Asset loading on main thread
Large allocations
Solutions :
#![allow(unused)]
fn main() {
// Pre-warm asset loading
fn warmup_system(asset_server: Res<AssetServer>) {
asset_server.load::<Mesh>("meshes/common.gltf");
asset_server.load::<Texture>("textures/atlas.png");
}
// Use streaming for large assets
let handle = asset_server.load_async::<LargeAsset>("path").await;
}
Symptoms : High CPU, low GPU utilization
Causes :
Inefficient queries
Too many systems
Excessive allocations
Solutions :
#![allow(unused)]
fn main() {
// Combine related systems
fn combined_system(
mut query: Query<(&mut Transform, &Velocity, &mut Health)>,
) {
for (mut transform, velocity, mut health) in query.iter_mut() {
transform.translation += velocity.0;
health.regen();
}
}
}
Symptoms : Increasing memory over time
Causes :
Entity leaks
Cache growth
Asset retention
Solutions :
#![allow(unused)]
fn main() {
// Periodic cleanup
fn cleanup_system(
mut commands: Commands,
dead_entities: Query<Entity, With<Dead>>,
time: Res<Time>,
mut cleanup_timer: ResMut<CleanupTimer>,
) {
cleanup_timer.tick(time.delta());
if cleanup_timer.just_finished() {
for entity in dead_entities.iter() {
commands.entity(entity).despawn_recursive();
}
}
}
}
1. **Profile in Release**: Debug builds are 10-50x slower
2. **Measure Realistically**: Test with actual content, not empty scenes
3. **Test on Target Hardware**: Don't only test on dev machines
4. **Budget Early**: Set performance targets before development
5. **Automate Testing**: Catch regressions in CI
- **Premature Optimization**: Don't optimize without profiling data
- **Micro-benchmarks**: Real-world performance may differ
- **Ignoring Memory**: CPU speed means nothing if you're thrashing cache
- **Single-threaded Thinking**: Utilize all cores
Documentation Accuracy : All exports listed below are verified against actual lib.rs files as of v0.4.0. Coverage percentages sourced from Master Coverage Report .
Browse the Rust API documentation for all AstraWeave crates.
High-performance Entity Component System:
Export Description
WorldMain container for all ECS data
EntityLightweight entity handles with generational indices
AppApplication builder and runner
ScheduleSystem scheduling and ordering
ComponentTrait for component data
ResourceSingleton data storage
QueryEfficient component access patterns
CommandBufferDeferred entity operations
EventsEvent queues and readers
#![allow(unused)]
fn main() {
use astraweave_ecs::{World, Entity, App, Schedule, Component, Resource, Query, CommandBuffer};
}
View Full Documentation →
Core engine systems and AI infrastructure:
Module Description
capture_replayDeterministic frame recording/playback
perceptionAI world observation and filtering
schemaWorldSnapshot, PlanIntent, ActionStep
simGame state management
tool_sandboxSecure AI action execution
tool_vocabulary37-tool AI action vocabulary
validationInput/output validation
worldWorld state management
#![allow(unused)]
fn main() {
use astraweave_core::{
schema::{WorldSnapshot, PlanIntent, ActionStep},
capture_replay::CaptureReplay,
perception::PerceptionFilter,
};
}
Generate rustdoc: cargo doc -p astraweave-core --no-deps --open
AI orchestration layer with GOAP+LLM hybrid support:
Module Feature Gate Description
core_loop— Perception-Reasoning-Planning-Action loop
orchestrator— AI coordination trait
tool_sandbox— Tool validation and sandboxing
async_taskllm_orchestratorAsync LLM task wrapper
llm_executorllm_orchestratorLLM plan generation
goapllm_orchestratorGoal-oriented action planning
AIArbiterllm_orchestratorGOAP+LLM hybrid (101.7ns control)
#![allow(unused)]
fn main() {
use astraweave_ai::{core_loop, orchestrator::Orchestrator};
// With llm_orchestrator feature:
#[cfg(feature = "llm_orchestrator")]
use astraweave_ai::{AIArbiter, goap::GoapOrchestrator};
}
View Full Documentation → | Arbiter Guide →
Behavior systems:
BehaviorGraph - Hierarchical behavior nodes (BehaviorNode enum)
BehaviorContext - Shared context for tick execution
BehaviorStatus - Success/Failure/Running states
UtilityAI - Score-based decision making
#![allow(unused)]
fn main() {
use astraweave_behavior::{BehaviorGraph, BehaviorNode, BehaviorContext, BehaviorStatus};
}
Generate rustdoc: cargo doc -p astraweave-behavior --no-deps --open
LLM integration (Qwen3-8B via Ollama):
Export Description
LlmOrchestratorProvider-agnostic LLM coordination
FallbackChain4-tier fallback (Full LLM → Simplified → Heuristic → Emergency)
OllamaClientOllama API client
#![allow(unused)]
fn main() {
use astraweave_llm::{LlmOrchestrator, FallbackChain};
}
Generate rustdoc: cargo doc -p astraweave-llm --no-deps --open
wgpu 25.0.2 based rendering (40+ modules):
Category Key Modules
Pipeline camera, clustered, deferred, forward, renderer
Animation animation, animation_blending, skeleton, skinning_gpu
Lighting light, shadow, ibl_manager, post_fx_shader
Geometry mesh, culling, lod_generator, instancing
Materials material, texture_array, vertex_compression
Effects water, volumetric, particle_system
#![allow(unused)]
fn main() {
use astraweave_render::{
camera::Camera,
material::Material,
mesh::Mesh,
renderer::Renderer,
};
}
View Full Documentation →
Rapier3D 0.22 integration:
Module Performance Description
rigid_body— Physics bodies and dynamics
collider— Collision shapes and detection
character_controller114ns/move Player movement
spatial_hash99.96% fewer checks Broad-phase acceleration
async_scheduler— Parallel physics stepping
#![allow(unused)]
fn main() {
use astraweave_physics::{
rigid_body::RigidBody,
collider::Collider,
character_controller::CharacterController,
};
}
View Full Documentation →
SPH fluid simulation (A+ grade):
Module Description
solverSPH pressure/viscosity solver
surface_tensionSurface tension forces
boundaryDomain boundary handling
spatial_hashNeighbor lookup acceleration
#![allow(unused)]
fn main() {
use astraweave_fluids::{FluidWorld, Particle, FluidConfig};
}
View Full Documentation → | Fluids Guide →
Navigation and pathfinding:
Module Description
navmeshNavigation mesh generation
pathfindingA* and hierarchical planning
portal_graphRoom-to-room navigation
agentNavigation agent component
#![allow(unused)]
fn main() {
use astraweave_nav::{Navmesh, PathQuery, Agent};
}
View Full Documentation →
Combat and game mechanics:
Module Description
combat_physicsRaycast attacks, parry, iframes
damage_systemDamage calculation
ability_systemAbility cooldowns and effects
#![allow(unused)]
fn main() {
use astraweave_gameplay::combat_physics::perform_attack_sweep;
}
Generate rustdoc: cargo doc -p astraweave-gameplay --no-deps --open
Procedural terrain generation:
Module Description
voxel_meshMarching cubes (256 configurations)
biomeBiome distribution and blending
chunkTerrain chunking (15.06ms/chunk)
#![allow(unused)]
fn main() {
use astraweave_terrain::{VoxelMesh, Biome, TerrainChunk};
}
Generate rustdoc: cargo doc -p astraweave-terrain --no-deps --open | Terrain Guide →
Spatial audio with rodio:
Module Description
audio_engine4-bus mixer system (master, music, SFX, voice)
spatial3D audio positioning
crossfadeMusic transitions
#![allow(unused)]
fn main() {
use astraweave_audio::{AudioEngine, SpatialAudio};
}
View Full Documentation →
World streaming and partitioning:
Module Description
streamingAsync cell loading
partitionWorld partitioning
#![allow(unused)]
fn main() {
use astraweave_scene::{WorldCell, CellLoader};
}
Generate rustdoc: cargo doc -p astraweave-scene --no-deps --open
# Generate all API documentation
cargo doc --workspace --no-deps --open
# Generate for specific crate with all features
cargo doc --package astraweave-ai --all-features --no-deps --open
# Generate with private items (for internal development)
cargo doc --package astraweave-core --document-private-items --no-deps --open
Tier Crates Avg Coverage Status
Tier 1 (Critical)ecs, core, ai, render 75.3% ✅ Production
Tier 2 (Important)physics, nav, gameplay 72.6% ✅ Production
Tier 3 (Supporting)audio, scene, terrain 71.8% ✅ Production
Tier 4 (Specialized)fluids, llm, prompts 71.5% ✅ Production
See Master Coverage Report for detailed breakdown.
Crate : astraweave-ecsCoverage : 94.26%Tests : 1,200+
The Entity Component System is the foundation of AstraWeave, providing deterministic, cache-friendly entity management with archetype-based storage.
The central container for all entities, components, and resources.
#![allow(unused)]
fn main() {
use astraweave_ecs::World;
let mut world = World::new();
// Spawn entities
let entity = world.spawn((Position::default(), Velocity::default()));
// Access resources
world.insert_resource(GameTime::default());
let time = world.resource::<GameTime>();
}
Key Methods :
new() → World - Create empty world
spawn(bundle) → Entity - Create entity with components
despawn(entity) - Remove entity
insert_resource<R>(resource) - Add singleton resource
resource<R>() → &R - Get resource reference
query<Q>() → Query<Q> - Create component query
Lightweight 64-bit identifier (32-bit index + 32-bit generation).
#![allow(unused)]
fn main() {
use astraweave_ecs::Entity;
// Entities are created via World::spawn()
let entity = world.spawn(MyBundle::default());
// Check if entity exists
if world.contains(entity) {
// Entity is alive
}
// Create from raw (unsafe, for FFI)
let raw = Entity::from_raw(42, 1);
}
Properties :
8 bytes memory footprint
Copy, Clone, Hash, Eq
Safe to store in collections
Generation prevents use-after-free
Application builder with plugin support.
#![allow(unused)]
fn main() {
use astraweave_ecs::App;
App::new()
.add_plugin(DefaultPlugins)
.add_startup_system(setup)
.add_system(update)
.run();
}
Key Methods :
add_plugin<P>(plugin) - Add functionality bundle
add_startup_system(system) - Run once at start
add_system(system) - Add to main loop
add_system_to_stage(stage, system) - Add to specific stage
run() - Start main loop
System execution scheduler with parallel support.
#![allow(unused)]
fn main() {
use astraweave_ecs::{Schedule, SystemStage};
let mut schedule = Schedule::new();
schedule.add_system_to_stage(SystemStage::Update, my_system);
schedule.run(&mut world);
}
System Stages (in execution order):
First - Pre-frame setup
PreUpdate - Input processing
Update - Main game logic
PostUpdate - Physics, AI
Last - Rendering prep
Trait for data attached to entities (auto-implemented for 'static + Send + Sync).
#![allow(unused)]
fn main() {
use astraweave_ecs::Component;
#[derive(Component, Default)]
struct Position {
x: f32,
y: f32,
z: f32,
}
#[derive(Component)]
struct Velocity(Vec3);
#[derive(Component)]
struct Name(String);
}
Requirements :
'static lifetime
Send + Sync (for parallel systems)
Prefer small, focused components
Singleton data shared across systems.
#![allow(unused)]
fn main() {
use astraweave_ecs::Resource;
#[derive(Resource, Default)]
struct GameTime {
elapsed: f32,
delta: f32,
}
#[derive(Resource)]
struct Settings {
volume: f32,
difficulty: Difficulty,
}
// Access in systems
fn update_time(mut time: ResMut<GameTime>) {
time.elapsed += time.delta;
}
}
Efficient iteration over component combinations.
#![allow(unused)]
fn main() {
use astraweave_ecs::Query;
// Read-only query
fn read_system(query: Query<&Position>) {
for pos in query.iter() {
println!("Position: {:?}", pos);
}
}
// Mutable query
fn write_system(mut query: Query<&mut Position>) {
for mut pos in query.iter_mut() {
pos.x += 1.0;
}
}
// Multiple components
fn complex_query(query: Query<(&Position, &Velocity, Option<&Name>)>) {
for (pos, vel, name) in query.iter() {
// name is Option<&Name>
}
}
// Filters
fn filtered_query(query: Query<&Position, With<Player>>) {
// Only entities with Player component
}
}
Filter Types :
With<T> - Entity must have T
Without<T> - Entity must not have T
Or<(A, B)> - Entity has A or B
Added<T> - T was just added
Changed<T> - T was modified
Deferred entity/component modifications (thread-safe).
#![allow(unused)]
fn main() {
use astraweave_ecs::CommandBuffer;
fn spawn_system(mut commands: Commands) {
// Spawn entity with components
commands.spawn((Position::default(), Velocity::default()));
// Modify existing entity
commands.entity(some_entity)
.insert(Health(100))
.remove::<Poisoned>();
// Despawn
commands.entity(dead_entity).despawn();
}
}
Key Methods :
spawn(bundle) → EntityCommands - Queue entity spawn
entity(entity) → EntityCommands - Get entity commands
insert_resource<R>(resource) - Queue resource insert
remove_resource<R>() - Queue resource removal
Type-safe event channel for system communication.
#![allow(unused)]
fn main() {
use astraweave_ecs::{Events, EventReader, EventWriter};
struct DamageEvent {
target: Entity,
amount: f32,
}
fn damage_sender(mut events: EventWriter<DamageEvent>) {
events.send(DamageEvent { target: enemy, amount: 50.0 });
}
fn damage_receiver(mut events: EventReader<DamageEvent>) {
for event in events.iter() {
println!("Entity {:?} took {} damage", event.target, event.amount);
}
}
}
Event Lifecycle :
Events sent via EventWriter
Events read via EventReader
Events cleared at end of frame
Double-buffered for reliable delivery
AstraWeave uses archetype-based storage for optimal cache performance:
Archetype A: [Position, Velocity]
┌──────────┬──────────┐
│ Position │ Velocity │
├──────────┼──────────┤
│ (1,2,3) │ (0,1,0) │ Entity 1
│ (4,5,6) │ (1,0,0) │ Entity 2
│ (7,8,9) │ (0,0,1) │ Entity 3
└──────────┴──────────┘
Archetype B: [Position, Velocity, Health]
┌──────────┬──────────┬────────┐
│ Position │ Velocity │ Health │
├──────────┼──────────┼────────┤
│ (0,0,0) │ (0,0,0) │ 100 │ Entity 4
└──────────┴──────────┴────────┘
Benefits :
Cache-friendly iteration
No pointer chasing
Parallel-safe by design
Operation Latency Notes
World creation 25.8 ns Empty world
Entity spawn 420 ns With components
Component access <1 ns Direct archetype access
Query iteration ~2 ns/entity Cache-optimal
Feature Description Default
parallelParallel system execution ✅
tracingTracy profiling integration ❌
serdeSerialization support ❌
[dependencies]
astraweave-ecs = { version = "0.4", features = ["serde"] }
Crate : astraweave-aiCoverage : ~75%Tests : 400+
The AI system provides the core perception-reasoning-planning-action loop, orchestration, and tool validation for AI-native game development.
The fundamental perception → reasoning → planning → action cycle.
#![allow(unused)]
fn main() {
use astraweave_ai::core_loop::{CoreLoop, WorldSnapshot, PlanIntent, ActionStep};
// Build perception
let snapshot = WorldSnapshot {
t: game_time,
player: player_state,
me: companion_state,
enemies: detected_enemies,
pois: points_of_interest,
obstacles: obstacle_positions,
objective: current_objective,
};
// Execute AI cycle
let plan = orchestrator.plan(&mut world, &snapshot)?;
// Execute first action
if let Some(action) = plan.steps.first() {
execute_action(action);
}
}
Key Types :
WorldSnapshot - Filtered world state for AI perception
PlanIntent - Validated action sequence from AI
ActionStep - Individual executable action
Trait-based AI planning abstraction.
#![allow(unused)]
fn main() {
use astraweave_ai::orchestrator::Orchestrator;
pub trait Orchestrator: Send + Sync {
fn plan(&self, world: &mut World, snap: &WorldSnapshot) -> Result<PlanIntent>;
}
// Implementations provided:
// - RuleOrchestrator (classical if-then rules)
// - GoapOrchestrator (goal-oriented planning)
// - LlmOrchestrator (LLM-based reasoning, feature-gated)
// - HybridOrchestrator (GOAP + LLM)
}
Secure action validation preventing impossible/cheating actions.
#![allow(unused)]
fn main() {
use astraweave_ai::tool_sandbox::{ToolSandbox, ToolResult};
let sandbox = ToolSandbox::new();
// Validate action before execution
match sandbox.validate(&action, &world_state) {
ToolResult::Valid => execute(action),
ToolResult::Invalid(reason) => {
log::warn!("AI attempted invalid action: {}", reason);
}
}
}
Validation Examples :
Movement range limits
Line-of-sight requirements
Resource availability checks
Cooldown enforcement
Hybrid GOAP+LLM system with zero user-facing latency.
#![allow(unused)]
fn main() {
#[cfg(feature = "llm_orchestrator")]
use astraweave_ai::{AIArbiter, LlmExecutor, GoapOrchestrator, RuleOrchestrator};
let mut arbiter = AIArbiter::new(llm_executor, goap, bt);
// Game loop - returns instantly (101.7 ns)
let action = arbiter.update(&snapshot);
}
Performance : 101.7 ns GOAP control, 575 ns LLM polling
See Arbiter System for full documentation.
Goal-Oriented Action Planning implementation.
#![allow(unused)]
fn main() {
use astraweave_ai::goap::{GoapPlanner, Goal, Action, WorldState};
let mut planner = GoapPlanner::new();
// Define goals
planner.add_goal(Goal::new("kill_enemy")
.with_precondition("enemy_visible", true)
.with_effect("enemy_dead", true));
// Define actions
planner.add_action(Action::new("attack")
.with_precondition("has_weapon", true)
.with_precondition("in_range", true)
.with_effect("enemy_dead", true)
.with_cost(1.0));
// Plan
let plan = planner.plan(¤t_state, &goal_state)?;
}
Integration for the Veilweaver game mechanics (fate-weaving).
#![allow(unused)]
fn main() {
use astraweave_ai::veilweaver::{FateThread, Prophecy, ThreadWeaver};
let mut weaver = ThreadWeaver::new();
let thread = weaver.create_thread(prophecy);
// Weave fate during gameplay
weaver.weave(&mut world, thread)?;
}
The AI’s view of the world (filtered for perception).
#![allow(unused)]
fn main() {
pub struct WorldSnapshot {
pub t: f32, // Current game time
pub player: PlayerState, // Player information
pub me: CompanionState, // This AI's state
pub enemies: Vec<EnemyState>, // Detected enemies
pub pois: Vec<Poi>, // Points of interest
pub obstacles: Vec<IVec2>, // Obstacle positions
pub objective: Option<String>, // Current objective
}
pub struct CompanionState {
pub pos: IVec2, // Position
pub ammo: i32, // Ammunition
pub cooldowns: BTreeMap<String, f32>, // Ability cooldowns
pub morale: f32, // Morale level
}
}
Validated action sequence from AI reasoning.
#![allow(unused)]
fn main() {
pub struct PlanIntent {
pub plan_id: String, // Unique plan identifier
pub steps: Vec<ActionStep>, // Ordered actions
}
pub enum ActionStep {
MoveTo { x: i32, y: i32 },
Attack { target: u32, stance: String },
TakeCover { position: Option<(i32, i32)> },
UseAbility { ability: String, target: Option<u32> },
Wait { duration: f32 },
Interact { object: u32 },
}
}
Orchestrator Latency Use Case
RuleOrchestrator~100 ns Simple if-then logic
GoapOrchestrator3-50 µs Goal-oriented planning
BehaviorTreeOrchestrator~200 ns Behavior trees
UtilityOrchestrator~500 ns Utility-based scoring
LlmOrchestrator13-21s Deep reasoning (async)
HybridOrchestrator~100 ns GOAP + async LLM
Feature Description Default
llm_orchestratorLLM-based planning ❌
goapGoal-oriented planning ✅
behavior_treeBT integration ✅
utilityUtility AI ❌
[dependencies]
astraweave-ai = { version = "0.4", features = ["llm_orchestrator"] }
Operation Latency Notes
WorldSnapshot build ~500 ns Perception gathering
GOAP planning 3-50 µs Depends on action space
Tool validation ~100 ns Per action
Full AI cycle ~5 µs Typical case
Crate : astraweave-physicsCoverage : ~82%Tests : 657+
Physics simulation built on Rapier3D with character controllers, vehicles, destruction, and deterministic replay support.
Central physics simulation container.
#![allow(unused)]
fn main() {
use astraweave_physics::{PhysicsWorld, RigidBodyDesc, ColliderDesc};
let mut physics = PhysicsWorld::new();
// Create rigid body
let body = physics.create_rigid_body(RigidBodyDesc {
position: Vec3::new(0.0, 5.0, 0.0),
body_type: RigidBodyType::Dynamic,
mass: 1.0,
..Default::default()
});
// Add collider
physics.add_collider(body, ColliderDesc::sphere(0.5));
// Step simulation
physics.step(1.0 / 60.0);
}
Physics-based character movement with slopes, stairs, and ground detection.
#![allow(unused)]
fn main() {
use astraweave_physics::CharacterController;
let mut controller = CharacterController::new(CharacterConfig {
height: 1.8,
radius: 0.3,
step_height: 0.3,
max_slope: 45.0,
..Default::default()
});
// Move character
let movement = controller.move_character(
&physics,
desired_velocity,
delta_time,
);
// Check ground state
if controller.is_grounded() {
// Can jump
}
}
Vehicle physics with tire model and suspension.
#![allow(unused)]
fn main() {
use astraweave_physics::vehicle::{Vehicle, VehicleConfig, WheelConfig};
let vehicle = Vehicle::new(VehicleConfig {
chassis_mass: 1500.0,
wheels: vec![
WheelConfig { position: Vec3::new(-0.8, 0.0, 1.5), ..Default::default() },
WheelConfig { position: Vec3::new(0.8, 0.0, 1.5), ..Default::default() },
WheelConfig { position: Vec3::new(-0.8, 0.0, -1.5), ..Default::default() },
WheelConfig { position: Vec3::new(0.8, 0.0, -1.5), ..Default::default() },
],
..Default::default()
});
// Apply input
vehicle.set_steering(steering_angle);
vehicle.set_throttle(throttle);
vehicle.set_brake(brake);
}
Efficient broad-phase collision detection.
#![allow(unused)]
fn main() {
use astraweave_physics::SpatialHash;
let mut spatial = SpatialHash::new(10.0); // Cell size
// Insert objects
spatial.insert(entity_a, aabb_a);
spatial.insert(entity_b, aabb_b);
// Query nearby objects
let nearby = spatial.query_sphere(position, radius);
// Query potential collisions
let pairs = spatial.get_collision_pairs();
}
Performance : 99.96% collision check reduction vs brute force
Ragdoll physics with joint constraints.
#![allow(unused)]
fn main() {
use astraweave_physics::ragdoll::{Ragdoll, RagdollConfig, BoneConfig};
let ragdoll = Ragdoll::new(RagdollConfig {
bones: vec![
BoneConfig { name: "spine", parent: None, .. },
BoneConfig { name: "head", parent: Some(0), .. },
BoneConfig { name: "arm_l", parent: Some(0), .. },
// ...
],
..Default::default()
});
// Activate ragdoll on character death
ragdoll.activate(&mut physics, character_pose);
}
Cloth simulation with wind and collision.
#![allow(unused)]
fn main() {
use astraweave_physics::cloth::{Cloth, ClothConfig};
let cloth = Cloth::new(ClothConfig {
width: 10,
height: 10,
particle_mass: 0.1,
stiffness: 1000.0,
damping: 0.1,
..Default::default()
});
// Pin corners
cloth.pin_particle(0, 0);
cloth.pin_particle(9, 0);
// Apply wind
cloth.apply_force(wind_force);
}
Destructible objects with fracturing.
#![allow(unused)]
fn main() {
use astraweave_physics::destruction::{Destructible, FractureConfig};
let destructible = Destructible::new(FractureConfig {
health: 100.0,
fracture_threshold: 50.0,
piece_count: 8,
..Default::default()
});
// Apply damage
let fragments = destructible.apply_damage(damage, hit_point)?;
// Spawn fragment entities
for fragment in fragments {
commands.spawn(fragment);
}
}
Projectile physics with penetration and ricochet.
#![allow(unused)]
fn main() {
use astraweave_physics::projectile::{Projectile, ProjectileConfig};
let projectile = Projectile::new(ProjectileConfig {
velocity: Vec3::new(0.0, 0.0, 500.0),
mass: 0.01,
drag: 0.1,
gravity_scale: 1.0,
penetration_power: 50.0,
..Default::default()
});
// Step projectile
let hit = projectile.step(&physics, delta_time);
if let Some(hit_info) = hit {
// Handle impact
}
}
#![allow(unused)]
fn main() {
use astraweave_physics::RaycastResult;
// Single ray
if let Some(hit) = physics.raycast(origin, direction, max_distance) {
println!("Hit at {:?}, normal: {:?}", hit.point, hit.normal);
}
// Ray with filter
let hit = physics.raycast_filtered(origin, direction, max_distance, |entity| {
!entity.has::<Ghost>()
});
}
#![allow(unused)]
fn main() {
// Sphere cast
let hit = physics.spherecast(origin, radius, direction, max_distance);
// Box cast
let hit = physics.boxcast(origin, half_extents, direction, max_distance);
}
#![allow(unused)]
fn main() {
// Get all entities in sphere
let entities = physics.overlap_sphere(center, radius);
// Get all entities in box
let entities = physics.overlap_box(center, half_extents, rotation);
}
Shape Constructor Use Case
Sphere ColliderDesc::sphere(radius)Characters, projectiles
Box ColliderDesc::cuboid(hx, hy, hz)Crates, buildings
Capsule ColliderDesc::capsule(half_height, radius)Characters
Cylinder ColliderDesc::cylinder(half_height, radius)Pillars
Convex Hull ColliderDesc::convex_hull(points)Complex objects
Trimesh ColliderDesc::trimesh(vertices, indices)Static geometry
Heightfield ColliderDesc::heightfield(heights, scale)Terrain
#![allow(unused)]
fn main() {
use astraweave_physics::{CollisionGroups, Group};
// Define groups
const PLAYER: Group = Group::GROUP_1;
const ENEMY: Group = Group::GROUP_2;
const PROJECTILE: Group = Group::GROUP_3;
const ENVIRONMENT: Group = Group::GROUP_4;
// Player collides with enemies and environment
let player_groups = CollisionGroups::new(
PLAYER,
ENEMY | ENVIRONMENT,
);
// Projectile collides with everything
let projectile_groups = CollisionGroups::new(
PROJECTILE,
PLAYER | ENEMY | ENVIRONMENT,
);
}
AstraWeave physics is fully deterministic for replay and networking:
#![allow(unused)]
fn main() {
use astraweave_physics::PhysicsWorld;
// Same seed = same results
let mut physics_a = PhysicsWorld::with_seed(12345);
let mut physics_b = PhysicsWorld::with_seed(12345);
// Run simulation
for _ in 0..1000 {
physics_a.step(1.0 / 60.0);
physics_b.step(1.0 / 60.0);
}
// Results are bit-identical
assert_eq!(physics_a.checksum(), physics_b.checksum());
}
Operation Latency Notes
Rigid body step 2.97 µs Single body
Full physics tick 6.52 µs 100 bodies
Character move 114 ns Controller step
Raycast ~500 ns Typical scene
Spatial hash query ~50 ns Per cell
Feature Description Default
debug-renderPhysics visualization ❌
simdSIMD acceleration ✅
parallelParallel simulation ✅
serializeState serialization ❌
Crate : astraweave-fluidsCoverage : ~91%Tests : 2,404
Production-ready SPH fluid simulation with real-time rendering, terrain integration, and editor support.
Central manager for all water visual effects.
#![allow(unused)]
fn main() {
use astraweave_fluids::{WaterEffectsManager, WaterQualityPreset};
let mut manager = WaterEffectsManager::new(WaterQualityPreset::High);
// Configure effects
manager.enable_caustics(true);
manager.enable_god_rays(true);
manager.enable_foam(true);
// Update each frame
manager.update(delta_time, &water_state);
// Render
manager.render(&mut render_context);
}
Quality presets for different hardware targets.
#![allow(unused)]
fn main() {
use astraweave_fluids::WaterQualityPreset;
// Available presets
let ultra = WaterQualityPreset::Ultra; // 64 rays, 1024 particles
let high = WaterQualityPreset::High; // 32 rays, 512 particles
let medium = WaterQualityPreset::Medium; // 16 rays, 256 particles
let low = WaterQualityPreset::Low; // 8 rays, 128 particles
// Apply preset
let manager = WaterEffectsManager::new(high);
}
Predictive-Corrective Incompressible SPH solver.
#![allow(unused)]
fn main() {
use astraweave_fluids::pcisph_system::{PcisphSystem, PcisphConfig};
let config = PcisphConfig {
particle_count: 10000,
particle_radius: 0.1,
rest_density: 1000.0,
viscosity: 0.01,
surface_tension: 0.0728,
..Default::default()
};
let mut solver = PcisphSystem::new(config);
// Add particles
solver.spawn_particles(position, count);
// Step simulation
solver.step(delta_time);
// Get particle data for rendering
let positions = solver.positions();
let velocities = solver.velocities();
}
Unified particle solver handling fluids, foam, and spray.
#![allow(unused)]
fn main() {
use astraweave_fluids::unified_solver::{UnifiedSolver, ParticleType};
let mut solver = UnifiedSolver::new(config);
// Spawn different particle types
solver.spawn(ParticleType::Water, position, count);
solver.spawn(ParticleType::Foam, position, count);
solver.spawn(ParticleType::Spray, position, count);
// Step all particles together
solver.step(delta_time);
}
Underwater light caustic patterns.
#![allow(unused)]
fn main() {
use astraweave_fluids::caustics::{CausticRenderer, CausticConfig};
let config = CausticConfig {
resolution: 512,
intensity: 1.0,
speed: 0.5,
scale: 10.0,
..Default::default()
};
let mut caustics = CausticRenderer::new(config);
caustics.update(time, water_surface);
// Apply to underwater objects
caustics.apply_to_surface(&mut material);
}
Volumetric underwater light shafts.
#![allow(unused)]
fn main() {
use astraweave_fluids::god_rays::{GodRayRenderer, GodRayConfig};
let config = GodRayConfig {
ray_count: 32,
ray_length: 50.0,
intensity: 0.8,
decay: 0.95,
..Default::default()
};
let mut god_rays = GodRayRenderer::new(config);
god_rays.render(sun_direction, water_surface);
}
Surface foam simulation.
#![allow(unused)]
fn main() {
use astraweave_fluids::foam::{FoamSystem, FoamConfig};
let config = FoamConfig {
spawn_threshold: 2.0, // Velocity threshold
lifetime: 3.0, // Seconds
size: 0.1,
..Default::default()
};
let mut foam = FoamSystem::new(config);
foam.update(delta_time, &water_particles);
foam.render(&mut render_context);
}
Seamless water-terrain interaction.
#![allow(unused)]
fn main() {
use astraweave_fluids::terrain_integration::{WaterTerrainIntegration, WaterBodyConfig};
let integration = WaterTerrainIntegration::new();
// Define water body
let lake = WaterBodyConfig {
center: Vec3::new(100.0, 50.0, 100.0),
radius: 50.0,
depth: 10.0,
flow_direction: Vec3::ZERO,
..Default::default()
};
// Query water at position
let water_info = integration.sample_water(position, &terrain);
if water_info.is_submerged {
apply_buoyancy(water_info.depth);
}
}
Water interaction with structures.
#![allow(unused)]
fn main() {
use astraweave_fluids::building::{WaterBuildingIntegration, FloodConfig};
let mut building_water = WaterBuildingIntegration::new();
// Configure flooding
building_water.set_flood_config(FloodConfig {
water_level: 45.0,
rise_rate: 0.1,
..Default::default()
});
// Update with building geometry
building_water.update(&buildings, delta_time);
// Query flood state
let flood = building_water.get_flood_level(building_id);
}
GPU-accelerated 3D volume grid for water simulation.
#![allow(unused)]
fn main() {
use astraweave_fluids::gpu_volume::{GpuVolumeGrid, VolumeConfig};
let config = VolumeConfig {
resolution: [128, 64, 128],
cell_size: 0.5,
..Default::default()
};
let mut volume = GpuVolumeGrid::new(&device, config);
// Update from particles
volume.splat_particles(&particles);
// Read density
let density = volume.sample(position);
}
Full undo/redo support for editor integration.
#![allow(unused)]
fn main() {
use astraweave_fluids::editor::{WaterEditor, EditorCommand};
let mut editor = WaterEditor::new();
// Make changes
editor.execute(EditorCommand::SetWaterLevel(50.0));
editor.execute(EditorCommand::AddEmitter(emitter));
// Undo
editor.undo();
// Redo
editor.redo();
}
Real-time validation of water configurations.
#![allow(unused)]
fn main() {
use astraweave_fluids::editor::validation::{validate_water_body, ValidationResult};
let result = validate_water_body(&config);
match result {
ValidationResult::Valid => { /* OK */ }
ValidationResult::Warning(msg) => {
ui.show_warning(&msg);
}
ValidationResult::Error(msg) => {
ui.show_error(&msg);
return;
}
}
}
Full water state serialization.
#![allow(unused)]
fn main() {
use astraweave_fluids::serialization::{save_water_state, load_water_state};
// Save
let data = save_water_state(&manager)?;
std::fs::write("water.bin", &data)?;
// Load
let data = std::fs::read("water.bin")?;
let manager = load_water_state(&data)?;
}
Distance-based quality adjustment.
#![allow(unused)]
fn main() {
use astraweave_fluids::lod::{WaterLod, LodConfig};
let lod = WaterLod::new(LodConfig {
lod0_distance: 50.0, // Full quality
lod1_distance: 100.0, // Medium
lod2_distance: 200.0, // Low
..Default::default()
});
// Update based on camera
lod.update(camera_position);
// Get quality for water body
let quality = lod.get_quality(water_position);
}
Operation Latency Notes
PCISPH step (10K particles) ~2 ms GPU accelerated
Caustic update ~0.5 ms 512×512 texture
God ray render ~0.8 ms 32 rays
Foam update ~0.3 ms 1K particles
Volume splat ~0.2 ms 128³ grid
Feature Description Default
gpuGPU acceleration ✅
editorEditor integration ❌
serializeState serialization ✅
simdSIMD optimization ✅
Crate : astraweave-renderCoverage : ~68%Tests : 300+
Modern wgpu-based rendering with PBR materials, shadows, post-processing, GPU skinning, and mesh optimization.
Central rendering coordinator.
#![allow(unused)]
fn main() {
use astraweave_render::{Renderer, RendererConfig};
let config = RendererConfig {
width: 1920,
height: 1080,
vsync: true,
msaa_samples: 4,
..Default::default()
};
let mut renderer = Renderer::new(&window, config).await?;
// Render frame
renderer.begin_frame();
renderer.draw_scene(&scene);
renderer.end_frame();
}
GPU mesh resource management.
#![allow(unused)]
fn main() {
use astraweave_render::MeshRegistry;
let mut registry = MeshRegistry::new(&device);
// Register mesh
let mesh_id = registry.register(MeshData {
vertices: vertex_data,
indices: index_data,
bounds: aabb,
});
// Draw mesh
renderer.draw_mesh(mesh_id, &transform, &material);
}
PBR material system with hot reloading.
#![allow(unused)]
fn main() {
use astraweave_render::{MaterialManager, PbrMaterial};
let mut materials = MaterialManager::new(&device);
// Create material
let material = PbrMaterial {
albedo: Color::rgb(0.8, 0.2, 0.2),
metallic: 0.0,
roughness: 0.5,
normal_map: Some(normal_texture),
..Default::default()
};
let material_id = materials.register(material);
}
Image-Based Lighting for environment reflections.
#![allow(unused)]
fn main() {
use astraweave_render::IblManager;
let mut ibl = IblManager::new(&device);
// Load environment map
ibl.load_hdr("environment.hdr").await?;
// Bind to shader
ibl.bind(&mut render_pass);
}
Vertex format with bone weights.
#![allow(unused)]
fn main() {
use astraweave_render::skinning_gpu::{SkinnedVertex, SkinnedMesh};
let vertex = SkinnedVertex {
position: [0.0, 1.0, 0.0],
normal: [0.0, 1.0, 0.0],
uv: [0.5, 0.5],
bone_indices: [0, 1, 0, 0], // Up to 4 bones
bone_weights: [0.7, 0.3, 0.0, 0.0],
};
let skinned_mesh = SkinnedMesh::new(&device, vertices, indices);
}
GPU bone matrix upload.
#![allow(unused)]
fn main() {
use astraweave_render::skinning_gpu::BoneMatrixBuffer;
let mut bone_buffer = BoneMatrixBuffer::new(&device, max_bones);
// Update bone matrices each frame
bone_buffer.update(&queue, &bone_matrices);
// Bind for skinning
bone_buffer.bind(&mut render_pass);
}
37% memory reduction via octahedral normals and half-float UVs.
#![allow(unused)]
fn main() {
use astraweave_render::vertex_compression::{compress_vertices, CompressedVertex};
let compressed = compress_vertices(&vertices);
// 32 bytes → 20 bytes per vertex
assert!(std::mem::size_of::<CompressedVertex>() < std::mem::size_of::<Vertex>());
}
Automatic level-of-detail mesh generation.
#![allow(unused)]
fn main() {
use astraweave_render::lod_generator::{generate_lods, LodConfig};
let config = LodConfig {
target_ratios: vec![1.0, 0.5, 0.25, 0.1],
min_triangles: 100,
..Default::default()
};
let lods = generate_lods(&mesh, config);
// lods[0] = original
// lods[1] = 50% triangles
// lods[2] = 25% triangles
// lods[3] = 10% triangles
}
GPU instanced rendering for repeated objects.
#![allow(unused)]
fn main() {
use astraweave_render::instancing::{InstanceBuffer, InstanceData};
let mut instances = InstanceBuffer::new(&device, max_instances);
// Update instance data
let instance_data: Vec<InstanceData> = trees.iter()
.map(|t| InstanceData {
transform: t.transform.to_cols_array_2d(),
color: t.color.into(),
})
.collect();
instances.update(&queue, &instance_data);
// Draw all instances in one call
renderer.draw_instanced(mesh_id, &instances);
}
Multi-cascade shadow mapping for large scenes.
#![allow(unused)]
fn main() {
use astraweave_render::shadows::{CascadedShadowMaps, CsmConfig};
let config = CsmConfig {
cascade_count: 4,
resolution: 2048,
split_lambda: 0.5,
..Default::default()
};
let mut csm = CascadedShadowMaps::new(&device, config);
// Update cascades
csm.update(&camera, &sun_direction);
// Render shadow maps
for cascade in csm.cascades() {
renderer.render_shadow_pass(cascade, &shadow_casters);
}
}
Post-processing effect chain.
#![allow(unused)]
fn main() {
use astraweave_render::post_fx::{PostFxPipeline, PostFxConfig};
let config = PostFxConfig {
bloom_enabled: true,
bloom_intensity: 0.5,
tonemapping: Tonemapping::Aces,
exposure: 1.0,
..Default::default()
};
let mut post_fx = PostFxPipeline::new(&device, config);
// Apply post-processing
post_fx.process(&mut render_context, &scene_texture);
}
Available Effects :
Bloom
Tonemapping (ACES, Reinhard, Filmic)
Color grading
Vignette
Film grain
SSAO (optional)
Camera types live in the astraweave-camera crate as of the
Unified Camera campaign’s C.3.A sub-phase. The renderer consumes the
canonical RenderView upload contract exclusively via
Renderer::update_view; the historical Renderer::update_camera(&Camera)
and Renderer::update_camera_matrices(...) APIs were removed in C.3.C.
#![allow(unused)]
fn main() {
use astraweave_camera::{CameraProducer, FreeFly};
let camera = FreeFly {
position: Vec3::new(0.0, 5.0, 10.0),
yaw: 0.0,
pitch: 0.0,
fovy: 60_f32.to_radians(),
aspect: 16.0 / 9.0,
znear: 0.1,
zfar: 1000.0,
};
// Upload to the renderer via the canonical RenderView contract.
renderer.update_view(&camera.to_render_view());
}
For camera-relative rendering (used by certain shadow/atmospheric paths to
mitigate large-world float precision), use the concrete-only sibling method:
#![allow(unused)]
fn main() {
renderer.update_view(&camera.to_render_view_camera_relative());
}
For new camera implementations (orbit, follow, cinematic), implement the
CameraProducer trait from astraweave-camera. See the crate’s documentation
for the trait surface, and docs/current/CAMERA_CONVENTIONS.md for the
canonical convention reference.
Immediate-mode debug visualization.
#![allow(unused)]
fn main() {
use astraweave_render::debug_draw::DebugDraw;
let mut debug = DebugDraw::new(&device);
// Draw primitives
debug.line(start, end, Color::RED);
debug.sphere(center, radius, Color::GREEN);
debug.box3d(&aabb, Color::BLUE);
debug.arrow(origin, direction, Color::YELLOW);
// Submit
debug.flush(&mut render_pass);
}
Declarative render pipeline.
#![allow(unused)]
fn main() {
use astraweave_render::graph::{RenderGraph, RenderPass};
let mut graph = RenderGraph::new();
// Define passes
graph.add_pass(RenderPass::new("shadow")
.with_output("shadow_map", Format::Depth32Float));
graph.add_pass(RenderPass::new("gbuffer")
.with_output("albedo", Format::Rgba8Unorm)
.with_output("normal", Format::Rgba16Float)
.with_output("depth", Format::Depth32Float));
graph.add_pass(RenderPass::new("lighting")
.with_input("albedo")
.with_input("normal")
.with_input("shadow_map")
.with_output("hdr_color", Format::Rgba16Float));
// Execute
graph.execute(&mut renderer);
}
Operation Latency Notes
Mesh draw 21 ns Per instance
Vertex compression 21 ns Per vertex
Instancing overhead 2 ns Per instance
Shadow pass (2K) ~1 ms Per cascade
Post-FX (full) ~2 ms All effects
Feature Description Default
pbrPBR materials ✅
shadowsShadow mapping ✅
post-fxPost-processing ✅
debug-renderDebug visualization ❌
hot-reloadShader hot reload ❌
Crate : astraweave-navCoverage : ~78%Tests : 200+
Navmesh-based pathfinding with A*, portal graphs, and dynamic obstacle avoidance.
Navigation mesh for pathfinding.
#![allow(unused)]
fn main() {
use astraweave_nav::{NavMesh, NavMeshConfig};
let config = NavMeshConfig {
cell_size: 0.3,
cell_height: 0.2,
agent_height: 2.0,
agent_radius: 0.6,
max_slope: 45.0,
..Default::default()
};
// Build from geometry
let navmesh = NavMesh::build(&vertices, &indices, config)?;
// Query path
let path = navmesh.find_path(start, goal)?;
}
Agent with steering behaviors.
#![allow(unused)]
fn main() {
use astraweave_nav::{NavAgent, AgentConfig};
let mut agent = NavAgent::new(AgentConfig {
speed: 5.0,
acceleration: 10.0,
radius: 0.5,
..Default::default()
});
// Set destination
agent.set_destination(target_position);
// Update each frame
agent.update(delta_time, &navmesh);
// Get movement vector
let velocity = agent.velocity();
}
Hierarchical pathfinding for large worlds.
#![allow(unused)]
fn main() {
use astraweave_nav::PortalGraph;
let mut graph = PortalGraph::new();
// Add regions
graph.add_region("forest", forest_navmesh);
graph.add_region("castle", castle_navmesh);
// Connect regions via portals
graph.add_portal("forest", "castle", portal_polygon);
// Find cross-region path
let path = graph.find_path(start, goal)?;
}
Dynamic obstacle avoidance using velocity obstacles.
#![allow(unused)]
fn main() {
use astraweave_nav::ObstacleAvoidance;
let mut avoidance = ObstacleAvoidance::new();
// Add moving obstacles
for other in nearby_agents {
avoidance.add_obstacle(other.position, other.velocity, other.radius);
}
// Compute safe velocity
let safe_velocity = avoidance.compute(
agent.position,
agent.desired_velocity,
agent.radius,
delta_time,
);
}
#![allow(unused)]
fn main() {
use astraweave_nav::astar::{astar_search, PathResult};
let result = astar_search(&navmesh, start, goal);
match result {
PathResult::Found(path) => {
for waypoint in path {
// Follow path
}
}
PathResult::Partial(path) => {
// Closest reachable point
}
PathResult::NotFound => {
// No path exists
}
}
}
#![allow(unused)]
fn main() {
use astraweave_nav::path_smoothing::smooth_path;
let raw_path = navmesh.find_path(start, goal)?;
let smooth = smooth_path(&raw_path, &navmesh);
// Smooth path has fewer waypoints
assert!(smooth.len() <= raw_path.len());
}
Large-scale agent coordination.
#![allow(unused)]
fn main() {
use astraweave_nav::crowd::{CrowdManager, CrowdAgent};
let mut crowd = CrowdManager::new(&navmesh, max_agents);
// Add agents
let agent_id = crowd.add_agent(CrowdAgent {
position: spawn_point,
target: destination,
speed: 5.0,
..Default::default()
});
// Update all agents
crowd.update(delta_time);
// Get agent state
let agent = crowd.get_agent(agent_id);
}
Operation Latency Notes
A* pathfind 50-500 µs Depends on distance
NavMesh query ~1 µs Point location
Agent update ~100 ns Per agent
Crowd update (100) ~50 µs All agents
Crate : astraweave-audioCoverage : ~72%Tests : 150+
Spatial audio with mixer buses, crossfading, reverb zones, and audio occlusion.
Central audio system with mixer.
#![allow(unused)]
fn main() {
use astraweave_audio::{AudioEngine, AudioConfig};
let config = AudioConfig {
sample_rate: 48000,
buffer_size: 512,
..Default::default()
};
let mut audio = AudioEngine::new(config)?;
// Play sound
audio.play("sfx/explosion.ogg", PlayConfig::default());
// Update each frame
audio.update(listener_position, listener_orientation);
}
4-bus mixing system.
#![allow(unused)]
fn main() {
use astraweave_audio::mixer::{AudioMixer, Bus};
let mut mixer = AudioMixer::new();
// Configure bus volumes
mixer.set_volume(Bus::Master, 1.0);
mixer.set_volume(Bus::Music, 0.8);
mixer.set_volume(Bus::Sfx, 1.0);
mixer.set_volume(Bus::Voice, 1.0);
// Mute bus
mixer.set_muted(Bus::Music, true);
}
3D positioned audio source.
#![allow(unused)]
fn main() {
use astraweave_audio::SpatialSource;
let mut source = SpatialSource::new("ambient/wind.ogg");
source.set_position(Vec3::new(10.0, 0.0, 5.0));
source.set_looping(true);
source.set_min_distance(1.0);
source.set_max_distance(50.0);
source.play();
}
Dynamic music with layers and crossfading.
#![allow(unused)]
fn main() {
use astraweave_audio::music::{MusicPlayer, MusicLayer};
let mut music = MusicPlayer::new();
// Add layers
music.add_layer(MusicLayer::new("music/ambient.ogg"));
music.add_layer(MusicLayer::new("music/combat.ogg"));
music.add_layer(MusicLayer::new("music/boss.ogg"));
// Crossfade between layers
music.crossfade_to(1, Duration::from_secs(2)); // Combat music
}
Environment-based reverb.
#![allow(unused)]
fn main() {
use astraweave_audio::reverb::{ReverbZone, ReverbPreset};
let zone = ReverbZone::new(ReverbPreset::Cave)
.with_bounds(aabb)
.with_blend_distance(5.0);
// Add to audio system
audio.add_reverb_zone(zone);
}
Presets : Small, Medium, Large, Hall, Cave, Outdoor, Underwater
Raycast-based sound occlusion.
#![allow(unused)]
fn main() {
use astraweave_audio::occlusion::AudioOcclusion;
let mut occlusion = AudioOcclusion::new(&physics);
// Update occlusion for all sources
occlusion.update(listener_position, &sources);
// Apply to source
let factor = occlusion.get_factor(source_id);
source.set_volume(base_volume * factor);
}
Operation Latency Notes
Source play ~100 µs Initial setup
Mixer update ~50 µs Per frame
Spatial update ~1 µs Per source
Occlusion raycast ~5 µs Per source
AstraWeave is organized as a workspace of interconnected crates, each providing focused functionality. This reference documents the crate hierarchy, dependencies, and public APIs.
graph TB
subgraph Core["Core Layer"]
ECS[astraweave-ecs]
MATH[astraweave-math]
SDK[astraweave-sdk]
end
subgraph AI["AI Layer"]
AI_CORE[astraweave-ai]
LLM[astraweave-llm]
MEMORY[astraweave-memory]
PERSONA[astraweave-persona]
BEHAVIOR[astraweave-behavior]
end
subgraph Rendering["Rendering Layer"]
CAMERA[astraweave-camera]
RENDER[astraweave-render]
MATERIALS[astraweave-materials]
ASSET[astraweave-asset]
UI[astraweave-ui]
end
subgraph Simulation["Simulation Layer"]
PHYSICS[astraweave-physics]
NAV[astraweave-nav]
AUDIO[astraweave-audio]
end
subgraph Gameplay["Gameplay Layer"]
GAMEPLAY[astraweave-gameplay]
DIALOGUE[astraweave-dialogue]
QUESTS[astraweave-quests]
PCG[astraweave-pcg]
end
SDK --> ECS
SDK --> AI_CORE
SDK --> RENDER
SDK --> PHYSICS
AI_CORE --> ECS
AI_CORE --> LLM
AI_CORE --> MEMORY
RENDER --> ECS
RENDER --> ASSET
RENDER --> MATERIALS
GAMEPLAY --> ECS
GAMEPLAY --> AI_CORE
GAMEPLAY --> PHYSICS
The foundation Entity Component System providing deterministic, high-performance entity management.
[dependencies]
astraweave-ecs = "0.1"
Key Types:
Type Description
WorldContainer for all entities, components, and resources
EntityLightweight identifier for game objects
ComponentData attached to entities (derive macro available)
ResourceSingleton data shared across systems
QueryEfficient iteration over component combinations
CommandsDeferred entity/component modifications
Example:
#![allow(unused)]
fn main() {
use astraweave_ecs::prelude::*;
#[derive(Component)]
struct Position(Vec3);
#[derive(Component)]
struct Velocity(Vec3);
fn movement_system(mut query: Query<(&mut Position, &Velocity)>) {
for (mut pos, vel) in query.iter_mut() {
pos.0 += vel.0;
}
}
}
Features:
parallel - Enable parallel system execution (default)
tracing - Add performance tracing instrumentation
serde - Serialization support for components
Canonical camera types and the CameraProducer trait that all camera
implementations consume. Defines RenderView (the upload contract the renderer
consumes), Projection (perspective projection with both matrix and original
parameters preserved), and FreeFly (the engine’s free-fly producer, moved
here from astraweave-render during the Unified Camera campaign’s C.3.A
sub-phase). The renderer consumes RenderView exclusively — see Renderer::update_view.
[dependencies]
astraweave-camera = "0.1"
Key Types:
Type Description
FreeFlyEngine free-fly camera producer (position, yaw, pitch, fovy, aspect, znear, zfar)
ProjectionPerspective projection: derived matrix plus original fovy, aspect, znear, zfar
RenderViewCanonical upload contract: view + projection + inverses + position + view direction
CameraProducerTrait every camera implementation provides: fn to_render_view(&self) -> RenderView
CameraControllerInput handler for orbit / free-fly modes (keyboard, mouse, scroll)
Example:
#![allow(unused)]
fn main() {
use astraweave_camera::{CameraProducer, FreeFly, Projection, RenderView};
let camera = FreeFly {
position: Vec3::new(0.0, 5.0, 10.0),
yaw: 0.0,
pitch: 0.0,
fovy: 60_f32.to_radians(),
aspect: 16.0 / 9.0,
znear: 0.1,
zfar: 1000.0,
};
let render_view: RenderView = camera.to_render_view();
renderer.update_view(&render_view);
}
Features:
serde - Enables Serialize/Deserialize derives on Projection and RenderView (off by default)
The FreeFly as Camera alias pattern in caller code. Caller code
throughout the workspace currently imports FreeFly via a local alias:
#![allow(unused)]
fn main() {
use astraweave_camera::FreeFly as Camera;
}
This is a deliberate artifact of the Unified Camera campaign (sub-phase C.3.C,
commit 326d607c1). The canonical name is FreeFly; historically the type
was named Camera and lived in astraweave-render. The campaign renamed the
type to its proper home crate but preserved the historical name as a per-file
alias in caller code to keep migration diffs small. The alias appears in
roughly 30 caller files across engine examples and internal tests.
For new code, prefer FreeFly directly without the alias. The alias is a
migration convenience, not a recommended pattern for new code. See
docs/current/CAMERA_CONVENTIONS.md in the repository for the canonical
convention reference (yaw=0 forward direction, FOV semantics, near/far
handling, aspect-ratio guards, coordinate handedness).
Two-camera architecture. AstraWeave has two production camera producers,
each living in the crate that owns its primary use case:
FreeFly — engine-runtime camera, in astraweave-camera. Used by
every example crate, the cinematics renderer path, and any application
embedding the engine. Free-look mouse + WASD navigation pattern.
OrbitCamera — editor camera, in tools/aw_editor/src/viewport/camera.rs.
Implements CameraProducer (added in Unified Camera sub-phase C.4).
Used exclusively by the editor’s viewport. Spherical orbit around a
focal point, with picking, frustum extraction, smooth zoom animation,
and screen-space queries built in.
Both producers converge at the CameraProducer::to_render_view() contract;
the renderer consumes RenderView exclusively and doesn’t know which
producer created it. OrbitCamera lives in the editor crate (rather than
in astraweave-camera) because its surface is editor-specific (~15
methods for interactive picking, deserialization sanitize, bookmark
restore); the CameraProducer trait is the abstraction that lets
producers live with their concerns. New engine-runtime producers
(Follow, Cinematic, Debug per the SOTA roadmap) belong in
astraweave-camera alongside FreeFly; new editor-only producers
belong in tools/aw_editor/.
Timeline-based sequencer for cutscenes and scripted events, with
camera, animation, audio, and FX tracks. The canonical cinematics
camera keyframe is CameraKey — the single type all cinematics camera
state consolidated to during the Unified Camera campaign’s C.7 chapter.
The crate has no astraweave-* dependencies (its pos/look_at
are plain (f32, f32, f32) tuples, not glam types), so any crate can
depend on it without circular-dependency risk — the property that let
astraweave-gameplay, tools/aw_editor, and examples/cutscene_render_demo
all adopt it during C.7.
[dependencies]
astraweave-cinematics = "0.1"
Key Types:
Type Description
TimeNewtype over f32 seconds (Time(pub f32))
CameraKeyCanonical camera keyframe: { t: Time, pos, look_at, fov_deg } — look-at target model, FOV in degrees. Provides lerp and sanitize
TrackTrack variant: Camera { keyframes }, Animation, Audio, Fx
TimelineNamed collection of tracks with a duration
SequencerPlayback engine: seek / step(dt) emitting events
SequencerEventEvents emitted during playback (CameraKey, anim, audio, FX)
The cinematics camera upload path. Cinematics camera state reaches
the renderer through Renderer::tick_cinematics(dt, &mut camera), which
steps a loaded Timeline and dispatches CameraKey events to
apply_camera_key. That function sanitizes defensively (clamping
fov_deg, resolving degenerate look_at == pos) and converts each key
into a FreeFly producer — fov_deg becomes fovy in radians at this
boundary. FreeFly then produces a RenderView via the canonical
CameraProducer contract, consumed by Renderer::update_view. There is
no bespoke cinematics renderer API (per CAMERA_CONVENTIONS.md §2.9).
See the Rendering chapter’s Camera System section
for the full consolidation arc.
Note: the docs/src/core-systems/cinematics.md chapter is a
separate, older walkthrough that predates the C.7 consolidation and
documents an outdated rotation-based CameraKey. It carries a banner
to that effect and is pending a full rewrite (C.7.F). Treat this
reference entry and the rendering chapter as canonical until that
rewrite lands.
Mathematics library optimized for game development with SIMD acceleration.
[dependencies]
astraweave-math = "0.1"
Key Types:
Type Description
Vec2, Vec3, Vec4Vector types with operator overloading
Mat3, Mat4Matrix types for transforms
QuatQuaternion for rotations
TransformPosition + rotation + scale
AabbAxis-aligned bounding box
RayRay for intersection tests
Example:
#![allow(unused)]
fn main() {
use astraweave_math::prelude::*;
let pos = Vec3::new(1.0, 2.0, 3.0);
let rotation = Quat::from_axis_angle(Vec3::Y, 45.0_f32.to_radians());
let transform = Transform::from_translation(pos).with_rotation(rotation);
let world_pos = transform.transform_point(Vec3::ZERO);
}
High-level SDK that re-exports commonly used types and provides convenience APIs.
[dependencies]
astraweave-sdk = "0.1"
Re-exports:
#![allow(unused)]
fn main() {
pub use astraweave_ecs::prelude::*;
pub use astraweave_ai::prelude::*;
pub use astraweave_render::prelude::*;
pub use astraweave_physics::prelude::*;
pub use astraweave_audio::prelude::*;
pub use astraweave_input::prelude::*;
}
App Builder:
use astraweave_sdk::prelude::*;
fn main() {
App::new()
.add_plugin(DefaultPlugins)
.add_startup_system(setup)
.add_system(game_logic)
.run();
}
Core AI framework with perception, planning, and behavior systems.
[dependencies]
astraweave-ai = "0.1"
Key Types:
Type Description
AiAgentAI-controlled entity component
PerceptionBusSensory input aggregation
PlannerGoal-oriented action planning
BehaviorTreeBehavior tree execution
BlackboardShared AI state storage
AiToolTool definition for LLM agents
Example:
#![allow(unused)]
fn main() {
use astraweave_ai::prelude::*;
let mut agent = AiAgent::new()
.with_perception_radius(50.0)
.with_tick_budget_ms(8);
agent.add_goal(AiGoal::Patrol {
waypoints: vec![point_a, point_b, point_c]
});
}
Features:
llm - Enable LLM integration (requires astraweave-llm)
goap - Goal-Oriented Action Planning
utility - Utility AI scoring system
LLM integration for AI agents with tool calling and validation.
[dependencies]
astraweave-llm = "0.1"
Key Types:
Type Description
LlmClientHTTP client for LLM endpoints
LlmConfigConfiguration for model and endpoint
ToolCallStructured tool invocation from LLM
ToolResultValidated tool execution result
PromptBuilderFluent prompt construction
Example:
#![allow(unused)]
fn main() {
use astraweave_llm::prelude::*;
let config = LlmConfig {
endpoint: "http://localhost:11434".into(),
model: "hermes2-pro-mistral".into(),
temperature: 0.7,
max_tokens: 256,
};
let client = LlmClient::new(config);
let response = client.complete("What should I do next?").await?;
}
Supported Backends:
Ollama (local)
OpenAI-compatible APIs
Custom endpoints
Memory systems for AI agents including short-term, long-term, and episodic memory.
[dependencies]
astraweave-memory = "0.1"
Key Types:
Type Description
MemoryStoreCentral memory management
ShortTermMemoryRecent observations with decay
LongTermMemoryPersistent important memories
EpisodicMemoryEvent sequences and narratives
MemoryQuerySemantic memory retrieval
Behavior tree implementation with visual editor support.
[dependencies]
astraweave-behavior = "0.1"
Node Types:
Category Nodes
Composite Sequence, Selector, Parallel, RandomSelector
Decorator Inverter, Repeater, Succeeder, UntilFail
Leaf Action, Condition, Wait, SubTree
Example:
#![allow(unused)]
fn main() {
use astraweave_behavior::prelude::*;
let tree = BehaviorTree::new(
Selector::new(vec![
Sequence::new(vec![
Condition::new("has_target"),
Action::new("attack_target"),
]).into(),
Action::new("patrol").into(),
])
);
}
GPU rendering with Vulkan/DX12/Metal backends via wgpu.
[dependencies]
astraweave-render = "0.1"
Key Types:
Type Description
RendererMain rendering context
RenderPassConfigurable render pass
MeshVertex/index buffer pair
MaterialSurface properties and shaders
CameraView and projection configuration
LightPoint, directional, spot lights
Features:
pbr - Physically-based rendering (default)
shadows - Shadow mapping with CSM
post-process - Bloom, SSAO, tone mapping
skeletal - Skeletal animation
PBR material system with shader graph support.
[dependencies]
astraweave-materials = "0.1"
Material Properties:
Property Type Description
albedoColor or TextureBase color
metallicf32 or TextureMetallic factor (0-1)
roughnessf32 or TextureSurface roughness (0-1)
normalTextureNormal map
emissionColorEmissive color
aoTextureAmbient occlusion
Asset loading, caching, and hot-reloading.
[dependencies]
astraweave-asset = "0.1"
Key Types:
Type Description
AssetServerAsync asset loading
Handle<T>Reference-counted asset handle
AssetLoaderCustom loader trait
AssetEventLoad/unload notifications
Supported Formats:
Meshes : glTF 2.0, OBJ, FBX
Textures : PNG, JPEG, KTX2, DDS
Audio : WAV, OGG, MP3
Fonts : TTF, OTF
Immediate-mode UI with retained state for game interfaces.
[dependencies]
astraweave-ui = "0.1"
Key Types:
Type Description
UiContextUI state and input handling
WidgetBase widget trait
LayoutFlexbox-style layout
StyleVisual styling properties
Built-in Widgets:
Button, Label, TextInput
Slider, Checkbox, RadioGroup
Panel, ScrollView, Modal
ProgressBar, Tooltip
3D physics with Rapier backend.
[dependencies]
astraweave-physics = "0.1"
Key Types:
Type Description
RigidBodyDynamic, kinematic, or static body
ColliderCollision shape
PhysicsWorldPhysics simulation context
RayCastRay intersection queries
JointConstraints between bodies
Collider Shapes:
Ball, Cuboid, Capsule, Cylinder
ConvexHull, TriMesh, HeightField
Compound (multiple shapes)
Navigation mesh and pathfinding.
[dependencies]
astraweave-nav = "0.1"
Key Types:
Type Description
NavMeshNavigation mesh geometry
NavAgentPathfinding agent component
PathQueryPath computation request
NavObstacleDynamic obstacle
Features:
A* pathfinding with string pulling
Dynamic obstacle avoidance
Off-mesh links for jumps/ladders
Hierarchical pathfinding for large worlds
Spatial audio with multiple backends.
[dependencies]
astraweave-audio = "0.1"
Key Types:
Type Description
AudioSourcePositional audio emitter
AudioListenerSpatial audio receiver
AudioClipLoaded audio data
MixerAudio mixing and effects
Features:
3D spatial audio with HRTF
Reverb zones
Audio occlusion
Streaming for music
High-level gameplay systems and components.
[dependencies]
astraweave-gameplay = "0.1"
Systems:
Combat and damage
Inventory management
Status effects
Interactable objects
Save/load integration
Dialogue tree and conversation systems.
[dependencies]
astraweave-dialogue = "0.1"
Key Types:
Type Description
DialogueTreeBranching conversation graph
DialogueNodeSingle dialogue entry
DialogueControllerRuntime dialogue state
DynamicDialogueLLM-powered conversations
Quest tracking and objective systems.
[dependencies]
astraweave-quests = "0.1"
Key Types:
Type Description
QuestQuest definition
QuestLogPlayer’s active quests
ObjectiveQuest goal/task
QuestEventQuest state changes
Procedural content generation framework.
[dependencies]
astraweave-pcg = "0.1"
Generators:
Terrain heightmaps with erosion
Dungeon layouts
Item properties
NPC backstories (AI-enhanced)
Quest generation (AI-enhanced)
Visual editor for scenes, behavior trees, and materials.
cargo run -p aw_editor
Features:
Scene hierarchy view
Component inspector
Behavior tree editor
Material graph editor
Asset browser
Command-line asset processing.
cargo run -p aw_asset_cli -- --help
Commands:
import - Convert assets to engine format
pack - Create asset bundles
validate - Check asset integrity
optimize - Compress and optimize assets
Runtime debugging tools.
[dependencies]
aw_debug = "0.1"
Features:
Entity inspector overlay
Performance graphs
Physics debug visualization
AI state visualization
Console commands
Crate Feature Description
astraweave-ecsparallelParallel system execution
astraweave-ecstracingPerformance instrumentation
astraweave-aillmLLM integration
astraweave-aigoapGoal-oriented planning
astraweave-renderpbrPBR materials
astraweave-rendershadowsShadow mapping
astraweave-physicsdebug-renderPhysics visualization
astraweave-audiospatial3D audio
graph LR
subgraph External["External Dependencies"]
wgpu[wgpu]
rapier[rapier3d]
tokio[tokio]
serde[serde]
end
ECS[astraweave-ecs] --> serde
RENDER[astraweave-render] --> wgpu
RENDER --> ECS
PHYSICS[astraweave-physics] --> rapier
PHYSICS --> ECS
LLM[astraweave-llm] --> tokio
AI[astraweave-ai] --> ECS
AI --> LLM
This reference documents all configuration options for AstraWeave projects.
Standard Rust project configuration with AstraWeave dependencies:
[package]
name = "my_game"
version = "0.1.0"
edition = "2021"
[dependencies]
astraweave-core = "0.1"
astraweave-ecs = "0.1"
astraweave-ai = "0.1"
astraweave-physics = "0.1"
astraweave-render = "0.1"
astraweave-audio = "0.1"
astraweave-input = "0.1"
[features]
default = ["graphics"]
graphics = ["astraweave-render/wgpu"]
headless = []
profiling = ["astraweave-core/tracy"]
[profile.release]
lto = true
codegen-units = 1
opt-level = 3
Configure the engine at startup:
#![allow(unused)]
fn main() {
use astraweave_core::config::*;
let config = EngineConfig {
tick_rate: TickRate::Fixed(60),
window: WindowConfig {
title: "My Game".into(),
width: 1920,
height: 1080,
vsync: true,
fullscreen: false,
},
graphics: GraphicsConfig {
backend: GraphicsBackend::Auto,
msaa_samples: 4,
shadow_quality: ShadowQuality::High,
max_lights: 128,
},
physics: PhysicsConfig {
gravity: Vec3::new(0.0, -9.81, 0.0),
substeps: 4,
solver_iterations: 8,
},
audio: AudioConfig {
sample_rate: 48000,
channels: 2,
buffer_size: 1024,
},
ai: AiConfig {
tick_budget_ms: 8,
max_concurrent_plans: 4,
ollama_endpoint: "http://localhost:11434".into(),
default_model: "hermes2-pro-mistral".into(),
},
};
Engine::run(config, |world| {
// Game setup
});
}
Load configuration from file:
# config.toml
[engine]
tick_rate = 60
[window]
title = "My Game"
width = 1920
height = 1080
vsync = true
fullscreen = false
[graphics]
backend = "vulkan"
msaa_samples = 4
shadow_quality = "high"
max_lights = 128
hdr = true
bloom = true
[physics]
gravity = [0.0, -9.81, 0.0]
substeps = 4
solver_iterations = 8
ccd_enabled = true
[audio]
sample_rate = 48000
channels = 2
buffer_size = 1024
master_volume = 1.0
[ai]
tick_budget_ms = 8
max_concurrent_plans = 4
ollama_endpoint = "http://localhost:11434"
default_model = "hermes2-pro-mistral"
fallback_enabled = true
[navigation]
cell_size = 0.3
cell_height = 0.2
agent_radius = 0.5
agent_height = 1.8
max_slope = 45.0
[networking]
tick_rate = 30
interpolation_delay_ms = 100
max_clients = 32
Load in code:
#![allow(unused)]
fn main() {
use astraweave_core::config::EngineConfig;
let config = EngineConfig::from_file("config.toml")?;
}
#![allow(unused)]
fn main() {
let graphics = GraphicsConfig {
backend: GraphicsBackend::Vulkan,
msaa_samples: 4,
anisotropic_filtering: 16,
shadow_quality: ShadowQuality::High,
shadow_cascade_count: 4,
shadow_map_size: 2048,
max_lights: 128,
clustered_lighting: true,
cluster_dimensions: [16, 9, 24],
hdr: true,
bloom: true,
bloom_intensity: 0.5,
ambient_occlusion: AmbientOcclusion::Ssao,
vsync: VsyncMode::Enabled,
frame_limit: None,
};
}
#![allow(unused)]
fn main() {
let physics = PhysicsConfig {
gravity: Vec3::new(0.0, -9.81, 0.0),
substeps: 4,
solver_iterations: 8,
ccd_enabled: true,
ccd_max_substeps: 4,
broad_phase: BroadPhase::SweepAndPrune,
contact_skin: 0.01,
sleep_threshold_linear: 0.1,
sleep_threshold_angular: 0.05,
debug_render: false,
};
}
#![allow(unused)]
fn main() {
let ai = AiConfig {
tick_budget_ms: 8,
max_concurrent_plans: 4,
plan_queue_size: 16,
ollama_endpoint: "http://localhost:11434".into(),
default_model: "hermes2-pro-mistral".into(),
temperature: 0.7,
max_tokens: 256,
request_timeout_ms: 100,
plan_cache_enabled: true,
plan_cache_duration_ms: 2000,
fallback_enabled: true,
context_window_size: 4096,
tool_validation_strict: true,
};
}
#![allow(unused)]
fn main() {
let audio = AudioConfig {
sample_rate: 48000,
channels: 2,
buffer_size: 1024,
master_volume: 1.0,
music_volume: 0.8,
sfx_volume: 1.0,
voice_volume: 1.0,
max_simultaneous_sounds: 32,
spatial_audio: true,
hrtf_enabled: true,
reverb_enabled: true,
distance_model: DistanceModel::InverseSquare,
rolloff_factor: 1.0,
reference_distance: 1.0,
max_distance: 100.0,
};
}
#![allow(unused)]
fn main() {
let navigation = NavigationConfig {
cell_size: 0.3,
cell_height: 0.2,
agent_radius: 0.5,
agent_height: 1.8,
agent_max_climb: 0.4,
agent_max_slope: 45.0,
region_min_size: 8,
region_merge_size: 20,
edge_max_len: 12.0,
edge_max_error: 1.3,
verts_per_poly: 6,
detail_sample_distance: 6.0,
detail_sample_max_error: 1.0,
tile_size: 64,
pathfinder: PathfinderConfig {
max_iterations: 2048,
heuristic_scale: 1.0,
},
crowd: CrowdConfig {
max_agents: 128,
avoidance_quality: AvoidanceQuality::High,
},
};
}
#![allow(unused)]
fn main() {
let input = InputConfig {
deadzone: 0.15,
mouse_sensitivity: 1.0,
mouse_invert_y: false,
mouse_smoothing: true,
gamepad_enabled: true,
gamepad_vibration: true,
action_repeat_delay_ms: 500,
action_repeat_rate_ms: 50,
};
}
Override configuration with environment variables:
Variable Description Default
ASTRAWEAVE_LOGLog level (trace, debug, info, warn, error) info
ASTRAWEAVE_GRAPHICS_BACKENDGraphics backend (vulkan, dx12, metal) Auto
ASTRAWEAVE_OLLAMA_ENDPOINTOllama API endpoint http://localhost:11434
ASTRAWEAVE_OLLAMA_MODELDefault LLM model hermes2-pro-mistral
ASTRAWEAVE_TICK_RATESimulation tick rate 60
ASTRAWEAVE_HEADLESSRun without graphics false
Example:
ASTRAWEAVE_LOG=debug ASTRAWEAVE_HEADLESS=true cargo run -p my_game
[target.'cfg(windows)'.graphics]
backend = "dx12"
[target.'cfg(target_os = "linux")'.graphics]
backend = "vulkan"
[target.'cfg(target_os = "macos")'.graphics]
backend = "metal"
#![allow(unused)]
fn main() {
#[cfg(debug_assertions)]
let debug_config = DebugConfig {
show_fps: true,
show_entity_count: true,
physics_debug_draw: true,
navmesh_debug_draw: false,
ai_debug_overlay: true,
memory_stats: true,
profiler_enabled: true,
};
}
AstraWeave provides command-line tools for development, asset processing, debugging, and deployment.
Tool Purpose
aw_buildBuild and package games
aw_asset_cliAsset processing and optimization
aw_debugRuntime debugging and inspection
aw_editorVisual editor (GUI)
aw_headlessHeadless server for testing
aw_save_cliSave file management
Build and package games for distribution.
cargo install --path tools/aw_build
# Build for current platform
aw_build
# Build release version
aw_build --release
# Build for specific platform
aw_build --target windows
aw_build --target linux
aw_build --target macos
aw_build --target web
# Full package with assets
aw_build --release --package
# Custom output directory
aw_build --release --output dist/
Create aw_build.toml in your project root:
[package]
name = "my-game"
version = "1.0.0"
author = "Developer Name"
description = "An awesome game"
[build]
features = ["release-optimizations"]
target_dir = "target"
[assets]
source = "assets"
compress = true
strip_unused = true
[platforms.windows]
icon = "assets/icons/game.ico"
installer = true
[platforms.linux]
appimage = true
[platforms.macos]
bundle = true
icon = "assets/icons/game.icns"
[platforms.web]
wasm_opt = true
# Debug build (fast compile, slow runtime)
aw_build --profile debug
# Release build (slow compile, optimized)
aw_build --profile release
# Distribution build (maximum optimization)
aw_build --profile dist
Process and optimize game assets.
cargo install --path tools/aw_asset_cli
# Import a single asset
aw_asset_cli import models/character.fbx
# Import with options
aw_asset_cli import models/character.fbx --format gltf --optimize
# Batch import directory
aw_asset_cli import-dir raw_assets/ --recursive
# Optimize textures
aw_asset_cli optimize textures/ --compress --generate-mipmaps
# Optimize meshes
aw_asset_cli optimize-mesh models/ --simplify 0.5
# Full optimization pass
aw_asset_cli optimize-all assets/
# Create asset bundle
aw_asset_cli pack assets/ --output game.pak
# Pack with compression
aw_asset_cli pack assets/ --output game.pak --compression zstd
# Pack specific types
aw_asset_cli pack assets/textures/ --output textures.pak --type texture
# Check asset integrity
aw_asset_cli validate assets/
# Check specific asset
aw_asset_cli validate models/character.gltf --verbose
# Validate bundle
aw_asset_cli validate-bundle game.pak
# Convert to KTX2 format
aw_asset_cli texture convert input.png --format ktx2
# Generate mipmaps
aw_asset_cli texture mipmaps input.png
# Compress textures
aw_asset_cli texture compress textures/ --format bc7
# Create texture atlas
aw_asset_cli texture atlas sprites/*.png --output atlas.png --json atlas.json
# Simplify mesh
aw_asset_cli mesh simplify model.gltf --ratio 0.5 --output model_lod1.gltf
# Generate LODs
aw_asset_cli mesh lod model.gltf --levels 3
# Optimize vertex cache
aw_asset_cli mesh optimize model.gltf --vertex-cache --output optimized.gltf
Runtime debugging and inspection tools.
cargo install --path tools/aw_debug
# Connect to local game
aw_debug attach
# Connect to specific port
aw_debug attach --port 9999
# Connect to remote game
aw_debug attach --host 192.168.1.100 --port 9999
> help # Show available commands
> entities # List all entities
> entity 42 # Inspect entity 42
> components Player # Find entities with Player component
> resource GameState # Inspect resource
> systems # List running systems
> perf # Show performance stats
> mem # Show memory usage
> spawn Enemy 10.0 0.0 5.0 # Spawn entity at position
> despawn 42 # Despawn entity
> pause # Pause game
> resume # Resume game
> step # Step one frame
> quit # Disconnect
# Run commands from file
aw_debug attach --script debug_commands.txt
# Run single command
aw_debug attach --exec "entities | grep Player"
# Dump state to file
aw_debug attach --exec "dump world.json"
# Start profiling
aw_debug attach --profile
# Capture Tracy trace
aw_debug attach --tracy --output trace.tracy
# Memory profiling
aw_debug attach --memory-profile --output memory.json
Visual editor for scene creation and game development.
cargo install --path tools/aw_editor
# Open editor
aw_editor
# Open specific project
aw_editor --project path/to/project
# Open specific scene
aw_editor --project myproject --scene levels/main.scene
Scene hierarchy view
Component inspector
Asset browser
Behavior tree editor
Material editor
Play-in-editor
Shortcut Action
Ctrl+S Save scene
Ctrl+Shift+S Save all
Ctrl+Z Undo
Ctrl+Shift+Z Redo
F5 Play
Shift+F5 Stop
F Focus selected
Delete Delete selected
Run games without rendering for testing and CI.
cargo install --path tools/aw_headless
# Run game headlessly
aw_headless --project path/to/game
# Run for specific duration
aw_headless --project game --duration 60
# Run specific test scene
aw_headless --project game --scene test_level.scene
# Run with custom config
aw_headless --project game --config test_config.toml
# Run integration tests
aw_headless --project game --test integration
# Run performance benchmark
aw_headless --project game --benchmark --output results.json
# Run AI behavior tests
aw_headless --project game --test ai-behaviors --verbose
# GitHub Actions example
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --release
- name: Headless Tests
run: aw_headless --project . --test all --timeout 300
Manage game save files.
cargo install --path tools/aw_save_cli
# List saves
aw_save_cli list
# Inspect save
aw_save_cli inspect save_001.dat
# Validate save
aw_save_cli validate save_001.dat
# Export to JSON
aw_save_cli export save_001.dat --format json --output save.json
# Import from JSON
aw_save_cli import save.json --output save_new.dat
# Migrate save to new version
aw_save_cli migrate save_001.dat --to-version 2
Platform Default Location
Windows %APPDATA%/GameName/saves/
Linux ~/.local/share/GameName/saves/
macOS ~/Library/Application Support/GameName/saves/
# Quick iteration
cargo build
cargo run
# With asset hot-reload
ASTRAWEAVE_HOT_RELOAD=1 cargo run
# Build optimized release
aw_build --release
# Process assets
aw_asset_cli optimize-all assets/
# Package for distribution
aw_build --release --package
# Install tools
cargo install --path tools/aw_build
cargo install --path tools/aw_asset_cli
cargo install --path tools/aw_headless
# Build
aw_build --release
# Test
aw_headless --test all
# Package
aw_build --release --package --output artifacts/
Variable Description
ASTRAWEAVE_LOGLog level (trace, debug, info, warn, error)
ASTRAWEAVE_ASSETSAsset directory path
ASTRAWEAVE_CONFIGConfig file path
ASTRAWEAVE_HOT_RELOADEnable hot-reload (1/0)
ASTRAWEAVE_DEBUG_PORTDebug server port
AstraWeave targets multiple platforms with varying levels of support and feature availability.
Tier Definition Guarantees
Tier 1 Fully supported, tested in CI Full feature support, regular testing, priority bug fixes
Tier 2 Supported, community tested Most features work, issues addressed on best-effort basis
Tier 3 Experimental May work, no guarantees, community-maintained
Platform Tier Rendering AI/LLM Audio Networking
Windows 10/11 (x64) Tier 1 Vulkan, DX12 Full Full Full
Linux (x64) Tier 1 Vulkan Full Full Full
macOS (x64) Tier 2 Metal Full Full Full
macOS (Apple Silicon) Tier 2 Metal Full Full Full
Platform Tier Rendering AI/LLM Audio Networking
WebAssembly Tier 2 WebGPU, WebGL2 Remote only Web Audio WebSocket
Platform Tier Notes
PlayStation 5 Planned Post-1.0
Xbox Series X/S Planned Post-1.0
Nintendo Switch Planned Post-1.0
Platform Tier Notes
Android Tier 3 Experimental
iOS Tier 3 Experimental
Component Windows Linux macOS
OS Windows 10 (1909+) Ubuntu 20.04+ / Fedora 34+ macOS 11+
CPU x64, 4 cores x64, 4 cores x64 / Apple M1+
RAM 8 GB 8 GB 8 GB
GPU Vulkan 1.2 / DX12 Vulkan 1.2 Metal 2
Storage 2 GB 2 GB 2 GB
Component Specification
CPU 8+ cores, modern (2020+)
RAM 16 GB+
GPU RTX 2060 / RX 5700 / M1 Pro+
Storage SSD, 10 GB+
Feature Additional Requirements
Local LLM (Ollama) 16 GB+ RAM, fast SSD
GPU-accelerated LLM NVIDIA GPU with 8 GB+ VRAM, CUDA
Large context windows 32 GB+ RAM
Primary rendering backend for Windows and Linux.
[dependencies]
astraweave-render = { version = "0.1", features = ["vulkan"] }
Requirements :
Vulkan 1.2+
Up-to-date GPU drivers
Tested GPUs :
NVIDIA: GTX 1000+, RTX series
AMD: RX 400+, RX 5000+, RX 6000+, RX 7000+
Intel: UHD 600+, Arc series
Alternative backend for Windows.
[dependencies]
astraweave-render = { version = "0.1", features = ["dx12"] }
Requirements :
Windows 10 1909+
DirectX 12 capable GPU
Primary backend for macOS.
[dependencies]
astraweave-render = { version = "0.1", features = ["metal"] }
Requirements :
macOS 11 (Big Sur)+
Metal 2 capable GPU
Modern web rendering API.
[dependencies]
astraweave-render = { version = "0.1", features = ["webgpu"] }
Requirements :
Chrome 113+ / Firefox 115+ / Safari 17+
WebGPU enabled
Fallback for browsers without WebGPU.
[dependencies]
astraweave-render = { version = "0.1", features = ["webgl2"] }
Limitations :
Reduced shader capabilities
Lower texture limits
No compute shaders
Feature Windows Linux macOS Web
ECS Yes Yes Yes Yes
Physics Yes Yes Yes Yes
Audio Yes Yes Yes Yes
Input Yes Yes Yes Partial
Networking Yes Yes Yes WebSocket
Feature Windows Linux macOS Web
PBR Materials Yes Yes Yes Yes
Shadow Mapping Yes Yes Yes Limited
Post-Processing Yes Yes Yes Limited
Skeletal Animation Yes Yes Yes Yes
Compute Shaders Yes Yes Yes WebGPU only
Feature Windows Linux macOS Web
Behavior Trees Yes Yes Yes Yes
GOAP Yes Yes Yes Yes
Local LLM (Ollama) Yes Yes Yes No
Remote LLM Yes Yes Yes Yes
GPU LLM (CUDA) Yes Limited No No
# Native build
cargo build --release
# Cross-compile from Linux
cargo build --release --target x86_64-pc-windows-gnu
# Native build
cargo build --release
# With specific features
cargo build --release --features "vulkan,llm"
# Native build
cargo build --release
# Universal binary (x64 + ARM)
cargo build --release --target x86_64-apple-darwin
cargo build --release --target aarch64-apple-darwin
lipo -create -output libgame.dylib \
target/x86_64-apple-darwin/release/libgame.dylib \
target/aarch64-apple-darwin/release/libgame.dylib
# Install wasm-pack
cargo install wasm-pack
# Build for web
wasm-pack build --target web --release
# Optimize WASM
wasm-opt -O3 -o game_opt.wasm game.wasm
# astraweave.toml
[platform.windows]
renderer = "dx12" # or "vulkan"
vsync = true
fullscreen_mode = "borderless"
[platform.windows.audio]
backend = "wasapi"
[platform.linux]
renderer = "vulkan"
vsync = true
wayland = true # Enable Wayland support
[platform.linux.audio]
backend = "pulseaudio" # or "alsa", "pipewire"
[platform.macos]
renderer = "metal"
retina = true
native_fullscreen = true
[platform.macos.audio]
backend = "coreaudio"
[platform.web]
renderer = "webgpu" # or "webgl2"
canvas_id = "game-canvas"
[platform.web.audio]
backend = "webaudio"
DX12 may require Windows Update for latest features
Some anti-virus software may flag debug builds
Wayland compositor compatibility varies
Audio backend may need manual selection
Notarization required for distribution
Some features limited without Rosetta on Apple Silicon
No local file system access
LLM requires remote server
Thread support limited (SharedArrayBuffer)
Binary size considerations
Error: Failed to create DX12 device
Solution : Update GPU drivers and Windows.
Error: Failed to load Vulkan library
Solution :
# Ubuntu/Debian
sudo apt install vulkan-tools mesa-vulkan-drivers
# Fedora
sudo dnf install vulkan-loader vulkan-tools
Error: Metal device not found
Solution : Ensure running on macOS 11+ with Metal-capable GPU.
Error: WebGPU not available
Solution : Enable WebGPU in browser settings or use WebGL2 fallback:
[platform.web]
renderer = "webgl2"
# GitHub Actions example
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: macos-latest
target: x86_64-apple-darwin
#![allow(unused)]
fn main() {
#[cfg(target_os = "windows")]
#[test]
fn test_windows_specific() {
// Windows-only test
}
#[cfg(target_os = "linux")]
#[test]
fn test_linux_specific() {
// Linux-only test
}
#[cfg(target_arch = "wasm32")]
#[test]
fn test_wasm_specific() {
// WebAssembly-only test
}
}
A comprehensive glossary of terms used throughout the AstraWeave documentation.
The final stage of the AI loop where an agent executes planned behaviors through validated tool calls.
An mdBook preprocessor for creating callout blocks (warnings, notes, tips).
An AI-controlled entity in the game world with perception, planning, and action capabilities.
The AI system responsible for validating tool calls and ensuring agents can only perform sanctioned actions within game rules.
In ECS, a unique combination of component types. Entities with the same components share an archetype for cache-efficient storage.
The content processing workflow that converts raw assets (textures, models, audio) into engine-optimized formats.
A system for grouping multiple LLM inference requests to improve throughput.
A hierarchical AI decision-making structure where nodes represent conditions, actions, and control flow.
A shared data structure for AI agents to read and write state information.
A terrain zone with distinct environmental characteristics (forest, desert, etc.).
A physics component that handles player/NPC movement with collision detection.
A rendering technique that divides the view frustum into clusters for efficient many-light rendering.
In ECS, a piece of data attached to an entity. Components contain only data, not behavior.
The fundamental AI cycle: Perception -> Reasoning -> Planning -> Action.
A simulation where given the same inputs, the outputs are always identical. Essential for replay and networking.
An AI system that orchestrates game pacing, difficulty, and narrative events.
A data-oriented architecture pattern where entities are IDs, components are data, and systems are logic operating on components.
In ECS, a unique identifier (typically an integer with a generation counter) that components are attached to.
In memory systems, a recorded sequence of events that can be recalled for learning.
A simulation update rate that runs at a constant interval (e.g., 60Hz) regardless of frame rate.
One complete update cycle of the game, including input, simulation, and rendering.
An AI planning algorithm that finds optimal action sequences to achieve goals by searching through possible world states.
An entity ID with a generation counter to detect use-after-free bugs when entities are recycled.
An image format storing extended brightness values, commonly used for environment lighting.
A technique using environment maps to provide realistic ambient lighting.
A texture container format supporting GPU compression and mipmaps.
AI models (like GPT, Llama, Phi) used for natural language understanding and generation.
Rendering optimization that uses simpler geometry for distant objects.
A JavaScript-based diagramming tool integrated into mdBook for rendering flowcharts and diagrams.
Pre-calculated, progressively smaller versions of a texture for efficient rendering at different distances.
Metallic-Roughness-AO texture packing format used in PBR materials.
Unreal Engine 5’s virtualized geometry system. AstraWeave implements similar techniques for GPU-driven LOD.
A polygon mesh representing walkable areas for AI pathfinding.
The AI component that coordinates multiple agents and manages their interactions.
A rendering approach that simulates realistic light behavior using physical principles.
The first stage of the AI loop where agents observe the game world state.
The AI stage where goals are decomposed into action sequences.
An mdBook tool that transforms source files before rendering.
In ECS, a request for entities matching specific component patterns.
A technique combining information retrieval with LLM generation for context-aware responses.
A physics-based character simulation for realistic death/knockback animations.
The Rust physics engine used by AstraWeave.
An embedded scripting language for Rust, used in AstraWeave for game logic.
An isolated execution environment for AI tool calls, preventing unauthorized actions.
CPU instructions processing multiple data points simultaneously for performance.
A data structure for efficient spatial queries (collision detection, neighbor finding).
In ECS, logic that operates on entities with specific component combinations.
One fixed timestep update of the simulation.
In AI systems, an action an agent can take, validated by the Arbiter.
The set of actions available to AI agents, each with schema and validation rules.
An AI approach where actions are scored by utility functions and the highest-scoring action is selected.
A 3D pixel, used in terrain systems for destructible/modifiable environments.
A Rust graphics library providing a cross-platform abstraction over Vulkan, Metal, DX12, and WebGPU.
The shader language used with WGPU.
The container for all entities, components, and resources in the engine.
Frequently asked questions about AstraWeave, the AI-native game engine.
AstraWeave is an AI-native game engine built in Rust. Unlike traditional engines that add AI as an afterthought, AstraWeave places intelligent agents at the core of its architecture. AI companions, enemies, and NPCs use the same validated game systems as players, ensuring fair and emergent gameplay.
Feature AstraWeave Unity/Unreal
AI Architecture AI-first, tool-validated AI as addon/plugin
LLM Integration Native, first-class Third-party plugins
Determinism Guaranteed by design Optional, complex
Language Rust (safe, fast) C#/C++
Multiplayer AI Same validation for AI & players Separate systems
AstraWeave is in active development (pre-1.0). Core systems are functional and tested, but APIs may change. We recommend it for:
Research and prototyping
Indie game development
Learning AI game development
Contributing to an open-source engine
Windows : Full support (primary development platform)
Linux : Full support
macOS : Supported (Apple Silicon native)
WebAssembly : Experimental (rendering only)
Consoles : Planned for post-1.0
AstraWeave uses Ollama for local LLM inference. Recommended models:
Model Use Case VRAM Required
hermes2-pro-mistralTool calling, dialogue 8GB
phi3:miniLightweight inference 4GB
llama3:8bGeneral purpose 8GB
mistral:7bFast inference 8GB
Not currently. AstraWeave is designed for local inference to ensure:
Deterministic behavior
Low latency (8ms budget per tick)
Privacy (no data leaves the machine)
Offline gameplay
Cloud support may be added post-1.0 for specific use cases.
All AI actions go through the Tool Validation System:
#![allow(unused)]
fn main() {
let tool_call = ToolCall {
tool: "move_to",
params: json!({ "target": [10.0, 0.0, 5.0] }),
};
match validator.validate(&tool_call, &world_state) {
ToolResult::Success(action) => execute(action),
ToolResult::Blocked(reason) => handle_failure(reason),
}
}
This prevents AI from cheating - it can only perform actions that would be valid for a player.
Common causes:
Ollama not running : Start with ollama serve
Model not pulled : Run ollama pull hermes2-pro-mistral
Port conflict : Check Ollama is on port 11434
Timeout : AI has an 8ms budget per tick; complex queries may timeout
Component Minimum Recommended
CPU 4 cores, 2.5 GHz 8 cores, 3.5 GHz
RAM 8 GB 16 GB
GPU GTX 1060 / RX 580 RTX 3070 / RX 6800
VRAM 4 GB 8 GB (for LLM)
Storage 10 GB 50 GB SSD
Debug builds : Always use --release for playable performance
LLM inference : Check Ollama is GPU-accelerated
Physics overhead : Reduce collision complexity
AI agents : Limit concurrent planning operations
AstraWeave integrates with Tracy profiler:
cargo run -p your_game --release --features profiling
See Performance Optimization for details.
AstraWeave’s ECS is designed for:
Deterministic iteration : Same order every tick
Generational entities : Safe entity references
AI integration : Perception and planning systems
Networking : State synchronization
It’s inspired by Bevy but optimized for AI-native gameplay.
#![allow(unused)]
fn main() {
let entity = world.spawn((
Transform::from_xyz(0.0, 1.0, 0.0),
RigidBody::Dynamic,
Collider::sphere(0.5),
Health { current: 100.0, max: 100.0 },
));
}
#![allow(unused)]
fn main() {
fn damage_system(
mut query: Query<(&mut Health, &Transform), With<Enemy>>,
) {
for (mut health, transform) in query.iter_mut() {
if in_danger_zone(transform) {
health.current -= 10.0;
}
}
}
}
git clone https://github.com/lazyxeon/AstraWeave-AI-Native-Gaming-Engine
cd AstraWeave-AI-Native-Gaming-Engine
cargo build --release
AstraWeave has many dependencies. First builds compile:
wgpu (graphics)
rapier (physics)
rodio (audio)
tokio (async runtime)
Subsequent builds are much faster due to incremental compilation.
cargo run -p hello_companion --release
cargo run -p physics_demo3d --release
cargo run -p unified_showcase --release
See Contributing Guide . In brief:
Fork the repository
Create a feature branch
Make changes with tests
Submit a pull request
This is expected behavior! The example demonstrates that AI cannot perform invalid actions. The companion tries to move but line-of-sight is blocked, proving the validation system works.
Ensure your GPU driver is up to date. WGPU requires:
Vulkan 1.1+ on Windows/Linux
Metal on macOS
WebGPU on browsers
This usually means an entity was despawned while still referenced. Use generational entities:
#![allow(unused)]
fn main() {
if world.get::<Health>(entity).is_some() {
// Entity still exists
}
}
Check audio device availability and ensure the audio example works:
cargo run -p audio_spatial_demo --release
AstraWeave uses a dual license:
Apache 2.0 : For most use cases
MIT : Alternative option
Commercial use is permitted under both licenses.
Yes! Both Apache 2.0 and MIT licenses permit commercial use. Attribution is appreciated but not required.
GitHub Issues : Bug reports and feature requests
GitHub Discussions : Questions and community chat
Documentation : This site
Examples : examples/ directory in the repository
Open a GitHub issue with:
AstraWeave version
Operating system
Steps to reproduce
Expected vs actual behavior
Error messages or logs
Quick reference for optimizing your AstraWeave games. For in-depth performance optimization, see the Performance Guide .
Always use release builds for performance testing:
cargo run --release -p your_game
Issue Solution
Low FPS Use release builds, reduce entity count
High memory Limit AI memory buffers, use asset streaming
AI lag Increase planning interval, reduce perception range
Render stutter Enable frustum culling, use LODs
#![allow(unused)]
fn main() {
ai_config.planning_interval = Duration::from_millis(500);
ai_config.perception_range = 15.0;
ai_config.max_concurrent_llm_requests = 2;
}
Process entities in batches:
#![allow(unused)]
fn main() {
fn process_entities(query: Query<(&Transform, &mut Velocity)>) {
query.par_iter_mut().for_each(|(transform, mut velocity)| {
velocity.0 = calculate_velocity(transform);
});
}
}
Metric Target Measurement
Frame time < 16.67ms 60 FPS
AI tick < 5ms Planning + execution
Physics step < 4ms Collision + dynamics
Render < 8ms Draw calls + GPU
Tracy : Real-time frame profiler
Cargo flamegraph : CPU profiling
RenderDoc : GPU debugging
cargo install cargo-flamegraph
cargo flamegraph --release -p your_game
This guide covers recommended practices for building production-quality games with AstraWeave, covering architecture, performance, AI integration, and maintainability.
Organize game code into focused modules:
my_game/
├── src/
│ ├── main.rs # Entry point
│ ├── lib.rs # Game library root
│ ├── player/ # Player systems
│ │ ├── mod.rs
│ │ ├── controller.rs
│ │ └── inventory.rs
│ ├── enemy/ # Enemy systems
│ │ ├── mod.rs
│ │ ├── ai.rs
│ │ └── spawner.rs
│ ├── world/ # World management
│ │ ├── mod.rs
│ │ ├── terrain.rs
│ │ └── weather.rs
│ └── ui/ # User interface
│ ├── mod.rs
│ ├── hud.rs
│ └── menus.rs
├── assets/
├── config/
└── Cargo.toml
Encapsulate features as plugins:
pub struct PlayerPlugin;
impl Plugin for PlayerPlugin {
fn build(&self, app: &mut App) {
app
.init_resource::<PlayerConfig>()
.add_systems(Startup, spawn_player)
.add_systems(Update, (
player_movement,
player_combat,
player_inventory,
).chain());
}
}
pub struct EnemyPlugin;
impl Plugin for EnemyPlugin {
fn build(&self, app: &mut App) {
app
.add_systems(Update, (
enemy_ai_tick,
enemy_spawning,
enemy_cleanup,
));
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(PlayerPlugin)
.add_plugins(EnemyPlugin)
.run();
}
Use resources for configuration and shared state:
#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct GameConfig {
pub difficulty: Difficulty,
pub render_distance: f32,
pub ai_budget_ms: f32,
}
#[derive(Resource)]
pub struct GameState {
pub current_level: String,
pub player_score: u64,
pub elapsed_time: f32,
}
fn game_system(
config: Res<GameConfig>,
mut state: ResMut<GameState>,
) {
// Access shared configuration and state
}
}
Do:
#![allow(unused)]
fn main() {
// Small, focused components
#[derive(Component)]
struct Health(f32);
#[derive(Component)]
struct MaxHealth(f32);
#[derive(Component)]
struct Damage(f32);
// Use marker components
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Enemy;
#[derive(Component)]
struct Hostile;
}
Don’t:
#![allow(unused)]
fn main() {
// Avoid monolithic components
#[derive(Component)]
struct Entity {
health: f32,
max_health: f32,
damage: f32,
position: Vec3,
rotation: Quat,
velocity: Vec3,
ai_state: AiState,
inventory: Vec<Item>,
// ... more fields
}
}
Group related systems with clear ordering:
#![allow(unused)]
fn main() {
app.add_systems(Update, (
// Input phase
read_player_input,
// AI phase (can run in parallel)
(enemy_perception, companion_perception).run_if(should_run_ai),
enemy_decision,
companion_decision,
// Movement phase
apply_movement,
resolve_collisions,
// Combat phase
process_attacks,
apply_damage,
check_deaths,
// Cleanup phase
despawn_dead_entities,
).chain());
}
#![allow(unused)]
fn main() {
// Good: Specific queries with filters
fn enemy_update(
enemies: Query<(&mut Transform, &mut AiAgent), (With<Enemy>, Without<Dead>)>,
) {
for (mut transform, mut ai) in enemies.iter_mut() {
// Only active enemies
}
}
// Good: Read-only where possible
fn render_system(
renderables: Query<(&Transform, &Mesh, &Material)>,
) {
for (transform, mesh, material) in renderables.iter() {
// Read-only access allows parallelism
}
}
// Good: Use change detection
fn on_health_changed(
query: Query<(Entity, &Health), Changed<Health>>,
) {
for (entity, health) in query.iter() {
// Only runs when health actually changes
}
}
}
When to use LLM:
Dynamic dialogue and conversation
Procedural narrative generation
Player behavior analysis
Creative content generation
When NOT to use LLM:
Real-time combat decisions (too slow)
Deterministic game logic
Anything requiring < 100ms response
#![allow(unused)]
fn main() {
// Layer AI appropriately
pub struct AiStack {
// Fast: Behavior trees for immediate reactions
pub behavior_tree: BehaviorTree,
// Medium: GOAP for tactical planning
pub planner: GoalPlanner,
// Slow: LLM for strategic/narrative decisions
pub llm_client: Option<LlmClient>,
}
impl AiStack {
pub fn update(&mut self, context: &AiContext) {
// Always run fast layer
self.behavior_tree.tick(context);
// Run planner when needed
if self.needs_replan(context) {
self.planner.plan(context);
}
// Async LLM calls for non-critical decisions
if self.should_consult_llm(context) {
self.queue_llm_request(context);
}
}
}
}
Always validate LLM tool calls:
#![allow(unused)]
fn main() {
pub fn validate_tool_call(call: &ToolCall, context: &GameContext) -> ToolResult {
match call.name.as_str() {
"move_to" => {
let target: Vec3 = call.parse_param("target")?;
// Validate target is reachable
if !context.navmesh.is_reachable(context.position, target) {
return ToolResult::Error("Target unreachable".into());
}
// Validate target is within range
let distance = context.position.distance(target);
if distance > context.max_move_distance {
return ToolResult::Error("Target too far".into());
}
ToolResult::Success(json!({ "path_found": true }))
}
"attack" => {
let target_id: u64 = call.parse_param("target")?;
// Validate target exists and is attackable
if !context.can_attack(target_id) {
return ToolResult::Error("Cannot attack target".into());
}
ToolResult::Success(json!({ "attack_initiated": true }))
}
_ => ToolResult::Error("Unknown tool".into()),
}
}
}
#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct FrameBudget {
pub target_fps: f32,
pub ai_budget_pct: f32,
pub physics_budget_pct: f32,
pub render_budget_pct: f32,
}
impl FrameBudget {
pub fn frame_time_ms(&self) -> f32 {
1000.0 / self.target_fps
}
pub fn ai_budget_ms(&self) -> f32 {
self.frame_time_ms() * self.ai_budget_pct
}
}
}
#![allow(unused)]
fn main() {
// Load assets asynchronously
fn load_level_async(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut loading_state: ResMut<LoadingState>,
) {
if loading_state.started {
return;
}
loading_state.handles.push(asset_server.load_async::<Scene>("levels/level1.scn"));
loading_state.started = true;
}
// Check loading progress
fn check_loading(
asset_server: Res<AssetServer>,
loading_state: Res<LoadingState>,
mut next_state: ResMut<NextState<GameState>>,
) {
if loading_state.handles.iter().all(|h| asset_server.is_loaded(h)) {
next_state.set(GameState::Playing);
}
}
}
#![allow(unused)]
fn main() {
// Use object pools for frequent spawn/despawn
#[derive(Resource)]
pub struct ProjectilePool {
available: Vec<Entity>,
active: HashSet<Entity>,
}
impl ProjectilePool {
pub fn get(&mut self, commands: &mut Commands) -> Entity {
if let Some(entity) = self.available.pop() {
commands.entity(entity).insert(Visible);
self.active.insert(entity);
entity
} else {
let entity = commands.spawn(ProjectileBundle::default()).id();
self.active.insert(entity);
entity
}
}
pub fn return_entity(&mut self, entity: Entity, commands: &mut Commands) {
commands.entity(entity).remove::<Visible>();
self.active.remove(&entity);
self.available.push(entity);
}
}
}
#![allow(unused)]
fn main() {
pub fn ai_system_with_fallback(
mut agents: Query<&mut AiAgent>,
llm: Option<Res<LlmClient>>,
) {
for mut agent in agents.iter_mut() {
let decision = if let Some(ref llm) = llm {
// Try LLM-based decision
match llm.try_decide(&agent.context) {
Ok(decision) => decision,
Err(e) => {
warn!("LLM failed: {}, using fallback", e);
agent.fallback_decision()
}
}
} else {
// No LLM available, use behavior tree
agent.behavior_tree.tick()
};
agent.execute(decision);
}
}
}
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum GameError {
#[error("Asset not found: {0}")]
AssetNotFound(String),
#[error("Invalid game state: {0}")]
InvalidState(String),
#[error("Network error: {0}")]
Network(#[from] NetworkError),
#[error("AI error: {0}")]
Ai(#[from] AiError),
}
pub type GameResult<T> = Result<T, GameError>;
}
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_damage_system() {
let mut world = World::new();
let entity = world.spawn((Health(100.0), DamageQueue::default())).id();
world.get_mut::<DamageQueue>(entity).unwrap().push(Damage(30.0));
let mut schedule = Schedule::default();
schedule.add_systems(apply_damage_system);
schedule.run(&mut world);
assert_eq!(world.get::<Health>(entity).unwrap().0, 70.0);
}
}
}
#![allow(unused)]
fn main() {
#[test]
fn test_combat_flow() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_plugins(CombatPlugin);
app.world.spawn((
Player,
Health(100.0),
Transform::default(),
));
app.world.spawn((
Enemy,
Health(50.0),
Transform::from_xyz(5.0, 0.0, 0.0),
));
// Simulate multiple frames
for _ in 0..100 {
app.update();
}
// Verify expected state
}
}
#![allow(unused)]
fn main() {
use tracing::{info, warn, error, debug, trace, instrument};
#[instrument(skip(query))]
fn my_system(query: Query<(&Transform, &Health)>) {
for (transform, health) in query.iter() {
trace!("Processing entity at {:?}", transform.translation);
if health.0 < 10.0 {
warn!("Entity at low health: {}", health.0);
}
}
}
}
#![allow(unused)]
fn main() {
fn debug_draw_system(
mut gizmos: Gizmos,
agents: Query<(&Transform, &AiAgent)>,
) {
#[cfg(debug_assertions)]
for (transform, agent) in agents.iter() {
// Draw perception radius
gizmos.circle(transform.translation, Vec3::Y, agent.perception_radius, Color::YELLOW);
// Draw path
if let Some(path) = &agent.current_path {
for window in path.windows(2) {
gizmos.line(window[0], window[1], Color::GREEN);
}
}
}
}
}
#![allow(unused)]
fn main() {
pub fn validate_player_input(input: &PlayerInput) -> Result<(), InputError> {
// Validate movement speed
if input.movement.length() > MAX_MOVEMENT_SPEED {
return Err(InputError::InvalidMovement);
}
// Validate action cooldowns
if input.action.is_some() && !can_perform_action() {
return Err(InputError::ActionOnCooldown);
}
Ok(())
}
}
#![allow(unused)]
fn main() {
use sha2::{Sha256, Digest};
pub fn save_game(state: &GameState) -> Result<(), SaveError> {
let data = bincode::serialize(state)?;
let hash = Sha256::digest(&data);
let save = SaveFile {
version: SAVE_VERSION,
data,
checksum: hash.to_vec(),
};
std::fs::write("save.dat", bincode::serialize(&save)?)?;
Ok(())
}
pub fn load_game() -> Result<GameState, LoadError> {
let bytes = std::fs::read("save.dat")?;
let save: SaveFile = bincode::deserialize(&bytes)?;
// Verify checksum
let hash = Sha256::digest(&save.data);
if hash.as_slice() != save.checksum {
return Err(LoadError::CorruptedSave);
}
Ok(bincode::deserialize(&save.data)?)
}
}
- [ ] All systems have error handling
- [ ] Performance profiled on target hardware
- [ ] AI fallbacks tested
- [ ] Save/load tested extensively
- [ ] Memory usage profiled
- [ ] Input validation complete
- [ ] Logging appropriate for production
- [ ] All assets properly loaded
- [ ] Multiplayer desync handled (if applicable)
This guide covers common design patterns used in AstraWeave game development, with practical examples and when to apply each pattern.
Use empty components to categorize entities:
#![allow(unused)]
fn main() {
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Enemy;
#[derive(Component)]
struct NPC;
#[derive(Component)]
struct Interactable;
// Query with markers
fn player_system(query: Query<&Transform, With<Player>>) {
for transform in query.iter() {
// Only players
}
}
fn enemy_system(query: Query<&Transform, (With<Enemy>, Without<Dead>)>) {
for transform in query.iter() {
// Only living enemies
}
}
}
Group related components for entity archetypes:
#![allow(unused)]
fn main() {
#[derive(Bundle)]
struct CharacterBundle {
transform: Transform,
health: Health,
velocity: Velocity,
collider: Collider,
}
#[derive(Bundle)]
struct PlayerBundle {
character: CharacterBundle,
player: Player,
inventory: Inventory,
controller: PlayerController,
}
#[derive(Bundle)]
struct EnemyBundle {
character: CharacterBundle,
enemy: Enemy,
ai: AiAgent,
loot_table: LootTable,
}
// Spawn with bundles
fn spawn_player(mut commands: Commands) {
commands.spawn(PlayerBundle {
character: CharacterBundle {
transform: Transform::default(),
health: Health(100.0),
velocity: Velocity::default(),
collider: Collider::capsule(0.5, 1.8),
},
player: Player,
inventory: Inventory::new(20),
controller: PlayerController::default(),
});
}
}
Store references between entities:
#![allow(unused)]
fn main() {
#[derive(Component)]
struct Parent(Entity);
#[derive(Component)]
struct Children(Vec<Entity>);
#[derive(Component)]
struct Target(Option<Entity>);
#[derive(Component)]
struct Owner(Entity);
fn targeting_system(
mut agents: Query<(&Transform, &mut Target), With<Enemy>>,
targets: Query<(Entity, &Transform), With<Player>>,
) {
for (agent_transform, mut target) in agents.iter_mut() {
let nearest = targets
.iter()
.min_by_key(|(_, t)| {
OrderedFloat(t.translation.distance(agent_transform.translation))
})
.map(|(e, _)| e);
target.0 = nearest;
}
}
}
Implement entity state machines:
#![allow(unused)]
fn main() {
#[derive(Component, Default)]
enum CharacterState {
#[default]
Idle,
Walking,
Running,
Jumping,
Attacking,
Stunned { duration: f32 },
Dead,
}
fn state_machine_system(
time: Res<Time>,
mut query: Query<(&mut CharacterState, &Velocity, &Health)>,
) {
let dt = time.delta_seconds();
for (mut state, velocity, health) in query.iter_mut() {
// Check for death
if health.0 <= 0.0 && !matches!(*state, CharacterState::Dead) {
*state = CharacterState::Dead;
continue;
}
// State transitions
*state = match &*state {
CharacterState::Stunned { duration } => {
let remaining = duration - dt;
if remaining <= 0.0 {
CharacterState::Idle
} else {
CharacterState::Stunned { duration: remaining }
}
}
CharacterState::Dead => CharacterState::Dead,
_ => {
if velocity.0.length() > 5.0 {
CharacterState::Running
} else if velocity.0.length() > 0.1 {
CharacterState::Walking
} else {
CharacterState::Idle
}
}
};
}
}
}
Use app states for high-level game flow:
#![allow(unused)]
fn main() {
#[derive(States, Debug, Clone, PartialEq, Eq, Hash, Default)]
enum GameState {
#[default]
Loading,
MainMenu,
Playing,
Paused,
GameOver,
}
fn setup_states(app: &mut App) {
app
.add_state::<GameState>()
.add_systems(OnEnter(GameState::Loading), start_loading)
.add_systems(OnEnter(GameState::MainMenu), show_menu)
.add_systems(OnExit(GameState::MainMenu), hide_menu)
.add_systems(Update, game_logic.run_if(in_state(GameState::Playing)))
.add_systems(OnEnter(GameState::Paused), show_pause_menu)
.add_systems(OnEnter(GameState::GameOver), show_game_over);
}
}
Store runtime configuration:
#![allow(unused)]
fn main() {
#[derive(Resource, Default)]
struct GameSettings {
master_volume: f32,
music_volume: f32,
sfx_volume: f32,
mouse_sensitivity: f32,
difficulty: Difficulty,
}
#[derive(Resource)]
struct LevelConfig {
spawn_rate: f32,
max_enemies: usize,
boss_health_multiplier: f32,
}
fn load_settings(mut settings: ResMut<GameSettings>) {
if let Ok(data) = std::fs::read_to_string("settings.toml") {
if let Ok(loaded) = toml::from_str(&data) {
*settings = loaded;
}
}
}
}
Encapsulate complex functionality:
#![allow(unused)]
fn main() {
#[derive(Resource)]
struct AudioManager {
music_channel: AudioChannel,
sfx_channels: Vec<AudioChannel>,
ambient_channel: AudioChannel,
}
impl AudioManager {
fn play_music(&mut self, track: &str, fade_in: f32) {
self.music_channel.fade_in(track, fade_in);
}
fn play_sfx(&mut self, sound: &str, position: Vec3) {
if let Some(channel) = self.sfx_channels.iter_mut().find(|c| c.is_available()) {
channel.play_spatial(sound, position);
}
}
}
#[derive(Resource)]
struct DialogueManager {
active_dialogue: Option<DialogueTree>,
history: Vec<DialogueEntry>,
}
}
Use events for decoupled communication:
#![allow(unused)]
fn main() {
#[derive(Event)]
struct DamageEvent {
target: Entity,
amount: f32,
damage_type: DamageType,
source: Option<Entity>,
}
#[derive(Event)]
struct DeathEvent {
entity: Entity,
killer: Option<Entity>,
position: Vec3,
}
#[derive(Event)]
struct ItemPickupEvent {
player: Entity,
item: ItemId,
quantity: u32,
}
fn damage_system(
mut damage_events: EventReader<DamageEvent>,
mut death_events: EventWriter<DeathEvent>,
mut query: Query<(&mut Health, &Transform)>,
) {
for event in damage_events.iter() {
if let Ok((mut health, transform)) = query.get_mut(event.target) {
health.0 -= event.amount;
if health.0 <= 0.0 {
death_events.send(DeathEvent {
entity: event.target,
killer: event.source,
position: transform.translation,
});
}
}
}
}
fn death_system(
mut commands: Commands,
mut death_events: EventReader<DeathEvent>,
loot_query: Query<&LootTable>,
) {
for event in death_events.iter() {
// Spawn loot
if let Ok(loot) = loot_query.get(event.entity) {
spawn_loot(&mut commands, loot, event.position);
}
// Play death effects
commands.spawn(ParticleEffect::death(event.position));
// Mark entity for removal
commands.entity(event.entity).insert(Dead);
}
}
}
Chain events for complex interactions:
#![allow(unused)]
fn main() {
// Event flow: Attack -> Hit -> Damage -> Death -> Loot -> XP
#[derive(Event)]
struct AttackEvent { attacker: Entity, weapon: Entity }
#[derive(Event)]
struct HitEvent { attacker: Entity, target: Entity, damage: f32 }
#[derive(Event)]
struct XpGainEvent { player: Entity, amount: u32 }
fn attack_resolution(
mut attacks: EventReader<AttackEvent>,
mut hits: EventWriter<HitEvent>,
weapons: Query<&Weapon>,
transforms: Query<&Transform>,
) {
for attack in attacks.iter() {
if let Ok(weapon) = weapons.get(attack.weapon) {
// Perform hit detection
for (entity, hit_point) in detect_hits(attack.attacker, weapon, &transforms) {
hits.send(HitEvent {
attacker: attack.attacker,
target: entity,
damage: weapon.damage,
});
}
}
}
}
}
Structure AI decisions hierarchically:
#![allow(unused)]
fn main() {
pub enum BtNode {
Selector(Vec<BtNode>),
Sequence(Vec<BtNode>),
Condition(Box<dyn Fn(&AiContext) -> bool>),
Action(Box<dyn Fn(&mut AiContext) -> BtStatus>),
}
#[derive(Clone, Copy, PartialEq)]
pub enum BtStatus {
Success,
Failure,
Running,
}
fn create_enemy_bt() -> BtNode {
BtNode::Selector(vec![
// Priority 1: Flee if low health
BtNode::Sequence(vec![
BtNode::Condition(Box::new(|ctx| ctx.health_percent() < 0.2)),
BtNode::Action(Box::new(|ctx| ctx.flee())),
]),
// Priority 2: Attack if in range
BtNode::Sequence(vec![
BtNode::Condition(Box::new(|ctx| ctx.has_target())),
BtNode::Condition(Box::new(|ctx| ctx.in_attack_range())),
BtNode::Action(Box::new(|ctx| ctx.attack())),
]),
// Priority 3: Chase target
BtNode::Sequence(vec![
BtNode::Condition(Box::new(|ctx| ctx.has_target())),
BtNode::Action(Box::new(|ctx| ctx.chase_target())),
]),
// Priority 4: Patrol
BtNode::Action(Box::new(|ctx| ctx.patrol())),
])
}
}
Share data between AI systems:
#![allow(unused)]
fn main() {
#[derive(Component, Default)]
pub struct Blackboard {
values: HashMap<String, BlackboardValue>,
}
#[derive(Clone)]
pub enum BlackboardValue {
Bool(bool),
Float(f32),
Int(i32),
Entity(Entity),
Vec3(Vec3),
String(String),
}
impl Blackboard {
pub fn set<K: Into<String>>(&mut self, key: K, value: BlackboardValue) {
self.values.insert(key.into(), value);
}
pub fn get(&self, key: &str) -> Option<&BlackboardValue> {
self.values.get(key)
}
pub fn get_entity(&self, key: &str) -> Option<Entity> {
match self.get(key) {
Some(BlackboardValue::Entity(e)) => Some(*e),
_ => None,
}
}
}
fn perception_system(
mut agents: Query<(&Transform, &Perception, &mut Blackboard)>,
targets: Query<(Entity, &Transform), With<Player>>,
) {
for (transform, perception, mut blackboard) in agents.iter_mut() {
let nearest = find_nearest_in_range(transform, &targets, perception.radius);
if let Some((entity, distance)) = nearest {
blackboard.set("target", BlackboardValue::Entity(entity));
blackboard.set("target_distance", BlackboardValue::Float(distance));
blackboard.set("has_target", BlackboardValue::Bool(true));
} else {
blackboard.set("has_target", BlackboardValue::Bool(false));
}
}
}
}
Organize systems with explicit ordering:
#![allow(unused)]
fn main() {
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
enum GameSystems {
Input,
Ai,
Movement,
Combat,
Effects,
Cleanup,
}
fn configure_systems(app: &mut App) {
app.configure_sets(Update, (
GameSystems::Input,
GameSystems::Ai,
GameSystems::Movement,
GameSystems::Combat,
GameSystems::Effects,
GameSystems::Cleanup,
).chain());
app
.add_systems(Update, read_input.in_set(GameSystems::Input))
.add_systems(Update, (
ai_perception,
ai_planning,
ai_execution,
).chain().in_set(GameSystems::Ai))
.add_systems(Update, (
apply_velocity,
resolve_collisions,
).chain().in_set(GameSystems::Movement));
}
}
Control when systems execute:
#![allow(unused)]
fn main() {
fn run_if_playing(state: Res<State<GameState>>) -> bool {
*state.get() == GameState::Playing
}
fn run_if_has_enemies(enemies: Query<(), With<Enemy>>) -> bool {
!enemies.is_empty()
}
fn run_every_n_frames(mut counter: Local<u32>) -> bool {
*counter = (*counter + 1) % 5;
*counter == 0
}
app
.add_systems(Update, game_logic.run_if(run_if_playing))
.add_systems(Update, enemy_ai.run_if(run_if_has_enemies))
.add_systems(Update, expensive_system.run_if(run_every_n_frames));
}
Share immutable data across entities:
#![allow(unused)]
fn main() {
#[derive(Resource)]
struct WeaponDatabase {
weapons: HashMap<WeaponId, WeaponData>,
}
#[derive(Clone)]
struct WeaponData {
name: String,
base_damage: f32,
attack_speed: f32,
mesh: Handle<Mesh>,
icon: Handle<Texture>,
}
#[derive(Component)]
struct Weapon {
id: WeaponId,
level: u32,
durability: f32,
}
impl Weapon {
fn get_data<'a>(&self, db: &'a WeaponDatabase) -> &'a WeaponData {
db.weapons.get(&self.id).expect("Invalid weapon ID")
}
fn calculate_damage(&self, db: &WeaponDatabase) -> f32 {
let data = self.get_data(db);
data.base_damage * (1.0 + self.level as f32 * 0.1)
}
}
}
React to component changes:
#![allow(unused)]
fn main() {
fn on_health_changed(
query: Query<(Entity, &Health, &MaxHealth), Changed<Health>>,
mut damage_flash: Query<&mut DamageFlash>,
) {
for (entity, health, max_health) in query.iter() {
let health_percent = health.0 / max_health.0;
// Trigger damage flash
if let Ok(mut flash) = damage_flash.get_mut(entity) {
flash.trigger();
}
// Update health bar
if health_percent < 0.25 {
// Critical health effects
}
}
}
fn on_entity_added(
query: Query<Entity, Added<Enemy>>,
mut enemy_count: ResMut<EnemyCount>,
) {
for _entity in query.iter() {
enemy_count.0 += 1;
}
}
}
Queue operations for later execution:
#![allow(unused)]
fn main() {
#[derive(Resource, Default)]
struct CommandQueue {
commands: Vec<GameCommand>,
}
enum GameCommand {
SpawnEnemy { position: Vec3, enemy_type: EnemyType },
DealDamage { target: Entity, amount: f32 },
GiveItem { player: Entity, item: ItemId },
TriggerEvent { event_name: String },
}
fn queue_command(queue: &mut CommandQueue, command: GameCommand) {
queue.commands.push(command);
}
fn execute_commands(
mut commands: Commands,
mut queue: ResMut<CommandQueue>,
mut damage_events: EventWriter<DamageEvent>,
) {
for command in queue.commands.drain(..) {
match command {
GameCommand::SpawnEnemy { position, enemy_type } => {
commands.spawn(EnemyBundle::new(enemy_type, position));
}
GameCommand::DealDamage { target, amount } => {
damage_events.send(DamageEvent { target, amount, .. });
}
// Handle other commands
_ => {}
}
}
}
}
Pattern Use Case
Marker Components Entity categorization
Bundles Entity archetypes
State Machine Entity behavior
App States Game flow
Events Decoupled communication
Behavior Tree AI decisions
Blackboard AI data sharing
System Sets Execution ordering
Run Conditions Conditional execution
Flyweight Shared data
Observer Change reactions
Command Queue Deferred execution
This guide helps you solve common issues when working with AstraWeave. Issues are organized by category with specific solutions.
error: rustc version doesn't match the expected version
Solution:
# Remove existing toolchain and reinstall
rustup toolchain uninstall stable
rustup toolchain install 1.89.0
rustup default 1.89.0
# Verify version
rustc --version # Should show 1.89.0
Solution:
# Force toolchain installation
rustup toolchain install 1.89.0
rustup override set 1.89.0
# Clean and rebuild
cargo clean
cargo build -p astraweave-core
Linux Solution:
sudo apt-get install build-essential
macOS Solution:
xcode-select --install
Windows Solution:
Install Visual Studio with C++ build tools.
error: could not find `wgpu` in the registry
Solution:
# Update Cargo registry
cargo update
# If still failing, clear cache
rm -rf ~/.cargo/registry
cargo update
Linux Solution:
# Ubuntu/Debian
sudo apt-get install mesa-vulkan-drivers vulkan-tools
# Arch Linux
sudo pacman -S vulkan-devel mesa
# Fedora
sudo dnf install vulkan-devel mesa-dri-drivers
# Verify Vulkan
vulkaninfo | head -20
Windows Solution:
Update your graphics drivers from manufacturer website:
NVIDIA: Download latest drivers
AMD: Download Adrenalin drivers
Intel: Download latest graphics drivers
macOS Solution:
Vulkan support requires MoltenVK:
brew install molten-vk
thread 'main' panicked at 'No suitable graphics adapter found'
Solutions:
Check GPU compatibility:
# Linux: Check Vulkan support
vulkaninfo
# Should show at least one device
Force software rendering:
export WGPU_BACKEND=gl
cargo run -p hello_companion
Update graphics drivers
ALSA lib pcm_dmix.c:1089:(snd_pcm_dmix_open) unable to open slave
Solution:
# Install audio libraries
sudo apt-get install libasound2-dev libpulse-dev
# Check audio devices
aplay -l
# If no devices, check PulseAudio
pulseaudio --check
Linux Solution:
# Restart audio services
systemctl --user restart pulseaudio
Windows Solution:
Check that Windows Audio service is running in Services.
macOS Solution:
Usually works out of the box. Check System Preferences > Sound.
Many examples have known compilation issues. Use only the working examples:
Working Examples:
cargo build -p hello_companion # ✅ Works (expected panic)
cargo build -p ipc_loopback # ✅ Should work
cargo test -p astraweave-input # ✅ Tests pass
Known Broken Examples:
# ❌ These have compilation issues:
# cargo build -p debug_toolkit_demo # egui/winit/renderer API mismatches
# cargo build -p aw_editor # eframe/glutin sync/send trait issues
# cargo build -p aw_debug # eframe API mismatches
# cargo build -p visual_3d # clippy deny-level errors
# cargo build -p navmesh_demo # clippy deny-level errors (approx_constant)
# cargo build -p physics_demo3d # clippy deny-level errors (approx_constant)
Workaround:
Focus on the working core components and use the provided aliases:
cargo build-core # Build core components only
cargo check-all # Check workspace (excluding problematic crates)
cargo clippy-all # Run clippy on working crates
wgpu validation error: Buffer usage VERTEX | COPY_DST is not valid
Solutions:
Update graphics drivers
Use older wgpu backend:
export WGPU_BACKEND=vulkan # or gl, metal, dx12
Reduce graphics settings in your code
Error creating surface: SurfaceError(OutOfMemory)
Solutions:
Reduce window size:
#![allow(unused)]
fn main() {
// In your window configuration
.with_inner_size(winit::dpi::LogicalSize::new(800, 600))
}
Lower graphics quality settings
Check available VRAM:
# Linux
nvidia-smi # for NVIDIA
radeontop # for AMD
Error: Could not load AI model 'companion-7b'
Solutions:
Use mock AI for testing:
#![allow(unused)]
fn main() {
// In your configuration
ai_agent.ai_model = AIModel::Mock;
}
Download required models:
# Models not included in repository
# Use mock or implement your own model loader
Configure model path:
#![allow(unused)]
fn main() {
ai_agent.ai_model = AIModel::Local("path/to/your/model".to_string());
}
Diagnosis:
# Always use release builds for performance testing
cargo run -p hello_companion --release
# Check if running in debug mode
cargo run -p hello_companion # This is debug mode - will be slow
Solutions:
Always use release builds:
cargo build --release
cargo run --release -p your_example
Check system resources:
# Linux
htop
# Monitor GPU usage
nvidia-smi # NVIDIA
radeontop # AMD
Reduce AI complexity:
#![allow(unused)]
fn main() {
// Lower AI planning frequency
ai_agent.planning_interval = Duration::from_millis(1000); // Instead of 500
// Reduce perception range
ai_agent.perception_range = 5.0; // Instead of 10.0
}
Diagnosis:
# Check memory usage
cargo run --release -p hello_companion &
ps aux | grep hello_companion
Solutions:
Limit AI memory:
#![allow(unused)]
fn main() {
ai_memory.max_episodic_memories = 100;
ai_memory.max_working_memory = 10;
}
Use memory profiling:
# Install valgrind (Linux)
sudo apt-get install valgrind
valgrind --tool=massif cargo run --release -p hello_companion
Error: Connection refused (os error 111)
Solutions:
Check if server is running:
# Terminal 1 - Start server first
cargo run -p coop_server --release
# Terminal 2 - Then client
cargo run -p coop_client --release
Check firewall settings:
# Linux: Check if port is open
sudo ufw status
# Allow port if needed
sudo ufw allow 8080
Use localhost:
#![allow(unused)]
fn main() {
// Make sure client connects to localhost
let server_addr = "127.0.0.1:8080";
}
Solution:
# Restart rust-analyzer
# In VS Code: Ctrl+Shift+P > "Rust Analyzer: Restart Server"
# Or reinstall
rustup component add rust-analyzer
Solutions:
Exclude target directory from indexing
Reduce project scope:
// In VS Code settings.json
{
"rust-analyzer.cargo.allFeatures": false,
"rust-analyzer.checkOnSave.allFeatures": false
}
cargo test -p astraweave-input
# Hangs indefinitely
Solutions:
Run with timeout:
timeout 30s cargo test -p astraweave-input
Run single test:
cargo test -p astraweave-input test_input_system
Use single-threaded execution:
cargo test -p astraweave-input -- --test-threads=1
thread 'ai_planning_test' panicked at 'assertion failed: plan.is_some()'
Solution:
Tests involving AI may have timing dependencies:
#![allow(unused)]
fn main() {
// Add delays in tests
#[test]
fn ai_planning_test() {
let mut world = create_test_world();
world.step(); // Let one frame pass
std::thread::sleep(Duration::from_millis(100)); // Give AI time to plan
let plan = world.get_ai_plan();
assert!(plan.is_some());
}
}
Some features may not work correctly on Wayland:
# Force X11 if needed
export WAYLAND_DISPLAY=""
export DISPLAY=:0
# Or force Wayland if X11 is causing issues
export DISPLAY=""
# Add user to audio group
sudo usermod -a -G audio $USER
# Restart session or reboot
error: codesign failed with exit code 1
Solution:
# For development, disable code signing
export MACOSX_DEPLOYMENT_TARGET=11.0
# Or sign manually
codesign --force --deep --sign - target/release/hello_companion
Use software rendering if Metal causes issues:
export WGPU_BACKEND=gl
cargo run -p hello_companion --release
Install Microsoft Visual C++ Redistributable:
Download from Microsoft’s website
Or install Visual Studio with C++ tools
Add exclusions for:
Project directory
%USERPROFILE%\.cargo
target\ directory
# Enable long paths in Windows
# Run as Administrator in PowerShell:
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force
Check this troubleshooting guide
Verify your setup:
rustc --version # Should be 1.89.0
cargo --version
Try with minimal example:
cargo run -p hello_companion --release
Check system dependencies
When reporting issues, include:
System information:
# Linux
uname -a
lsb_release -a
# macOS
sw_vers
# Windows
systeminfo
Rust version:
rustc --version
cargo --version
Graphics information:
# Linux
lspci | grep VGA
vulkaninfo | head -20
# Windows
dxdiag
# macOS
system_profiler SPDisplaysDataType
Full error output:
# Include full error with backtrace
RUST_BACKTRACE=full cargo run -p hello_companion 2>&1 | tee error.log
Steps to reproduce
GitHub Issues : For bug reports and feature requests
Discussions : For questions and general help
Matrix/Discord : For real-time community support (if available)
AstraWeave is under active development. Known limitations:
Many examples don’t compile due to API evolution
Limited AI model integration - mostly uses mock AI
Graphics API compatibility - some newer GPU features not supported
Documentation gaps - some advanced features lack documentation
Focus on working examples (hello_companion, core components)
Use mock AI for learning the architecture
Stick to stable APIs in core crates
Contribute fixes for broken examples
If you’re still having issues after trying these solutions, please create an issue on GitHub with the requested information. The community is here to help!
Welcome to the AstraWeave community! This page lists resources for connecting with other developers, getting help, and contributing to the project.
The primary home for AstraWeave development:
Resource Description
Repository Source code, issues, and pull requests
Discussions Q&A, ideas, and general discussion
Issues Bug reports and feature requests
Projects Development roadmap and tracking
Join our Discord server for real-time chat:
#general - Introduce yourself and chat with the community
#help - Get help with AstraWeave questions
#showcase - Share your projects and creations
#ai-discussion - Discuss AI/LLM integration topics
#development - Engine development discussion
#announcements - Official news and updates
Search existing resources :
Prepare your question :
Describe what you’re trying to do
Show what you’ve tried
Include relevant code snippets
Share error messages
Question Type Best Channel
Quick questions Discord #help
Detailed technical questions GitHub Discussions
Bug reports GitHub Issues
Feature requests GitHub Issues or Discussions
Security issues security@astraweave.dev (private)
## What I'm trying to do
I want to create an AI companion that follows the player and helps in combat.
## What I've tried
```rust
// My current code
fn companion_follow(
companion: Query<&mut Transform, With<Companion>>,
player: Query<&Transform, With<Player>>,
) {
// This doesn't work as expected
}
The companion doesn’t move at all.
The companion should move toward the player.
AstraWeave 0.1.0
Windows 11
## Contributing
### Ways to Contribute
Everyone can contribute, regardless of experience level:
| Contribution | Description |
|--------------|-------------|
| **Bug Reports** | Found a bug? Report it on GitHub Issues |
| **Documentation** | Improve or add documentation |
| **Code** | Fix bugs or implement features |
| **Examples** | Create example projects |
| **Testing** | Test pre-release versions |
| **Translations** | Help translate documentation |
| **Community** | Help others in Discord/Discussions |
### Getting Started
1. **Read the contributing guide**: [Contributing](../dev/contributing.md)
2. **Set up your development environment**: [Building](../dev/building.md)
3. **Find a good first issue**:
- Look for `good first issue` labels on GitHub
- Check the `help wanted` label for more challenging tasks
### Code Contribution Workflow
```bash
# 1. Fork and clone
git clone https://github.com/YOUR_USERNAME/astraweave.git
cd astraweave
# 2. Create a branch
git checkout -b feature/my-feature
# 3. Make changes
# ... edit files ...
# 4. Run tests
cargo test --all
# 5. Commit
git commit -am "feat: add my feature"
# 6. Push and create PR
git push origin feature/my-feature
Keep PRs focused : One feature or fix per PR
Write tests : Cover new functionality with tests
Update docs : Add documentation for new features
Follow style : Match existing code style
Be patient : Reviews take time
Share your AstraWeave projects!
Discord : Post in #showcase with:
Project name
Brief description
Screenshots or video
Link (if public)
GitHub Discussions : Create a post in the “Show and Tell” category
Projects using AstraWeave:
Project Description
hello_companion Official demo showcasing AI companion systems
adaptive-boss Boss AI that learns and adapts to player strategies
Have a project to share? Post in #showcase on Discord or open a Discussion on GitHub!
Monthly Showcase : First Friday of each month
AI Game Jam : Quarterly jam focusing on AI-native games
Office Hours : Weekly Q&A with maintainers
AstraWeave may be presented at:
GDC (Game Developers Conference)
RustConf
Various game development meetups
Role Responsibility
Core Team Major decisions, releases, roadmap
Maintainers Code review, issue triage
Contributors Feature development, bug fixes
Minor changes : Maintainer approval
Major changes : Core team discussion
Breaking changes : RFC process with community input
We follow a Code of Conduct to ensure a welcoming community:
Be respectful and inclusive
Focus on constructive feedback
Help others learn
No harassment or discrimination
Full Code of Conduct: CODE_OF_CONDUCT.md
Tutorials : Community-created tutorials (see Discord pinned messages)
Templates : Starter templates for common project types
Libraries : Community extensions and plugins
Getting Started : Installation
First Project : Building Your First Game
Core Concepts : Architecture Overview
AI Features : AI System
Advanced Topics : Explore specific system documentation
Each release includes a detailed changelog covering:
New features
Bug fixes
Breaking changes
Migration guides
See Roadmap for upcoming features and long-term plans.
If AstraWeave helps you, consider supporting its development:
GitHub Sponsors : Support individual maintainers
Corporate Sponsorship : Contact sponsorship@astraweave.dev
Thank you to our sponsors! Interested in sponsoring AstraWeave? Contact sponsorship@astraweave.dev to learn about sponsorship tiers and benefits.
Purpose Contact
General inquiries hello@astraweave.dev
Security issues security@astraweave.dev
Sponsorship sponsorship@astraweave.dev
Press press@astraweave.dev
This page reflects the engineering reality surfaced by the architecture trace
campaign as of `ARCHITECTURE_MAP.md` v0.7.0 (2026-05-13). It supersedes earlier
roadmap documents that framed designed-but-not-wired surface as shipping features.
For navigation, the [interactive workspace map](https://lazyxeon.github.io/AstraWeave-AI-Native-Gaming-Engine/architecture/)
visualises the same information.
AstraWeave is an active, solo-built, research-grade AI-native game engine in
development as the runtime for the Veilweaver game project. It is not a
shipping engine product. Its distinguishing engineering practice is the
architecture trace campaign — a 13-subsystem forensic documentation effort that
produces evidence-grounded, version-controlled traces of how the engine actually
works, separate from how older documentation aspirationally describes it.
The engine is built through AI-augmented development under the
Genesis Code Protocol (GCP) . The trace campaign is GCP applied at the
documentation/audit meta-level — a counterweight to the AI-generated drift that
otherwise accumulates in a 850K+ LoC workspace.
Deterministic ECS substrate — astraweave-ecs archetype storage,
generational Entity { id, generation }, deterministic single-threaded
scheduler (8 stages, executed in fixed order per tick). ParallelSchedule
removed 2026-04-18; parallelism lives at the subsystem level (rayon, tokio,
GPU compute) per docs/audits/parallel_schedule_removal_2026-04-18.md.
AI-first runtime loop — WorldSnapshot → AIArbiter → Orchestrator.plan()
→ PlanIntent → tool_sandbox → engine-side validate_and_execute. 12,700+
agents at 60 FPS validated. Canonical GOAP + Behavior Trees + LLM orchestrator
hybrid (astraweave-ai, astraweave-behavior, astraweave-llm).
Rendering — astraweave-render 78K LoC + 71 WGSL shaders. Disney BRDF +
multi-scatter PBR, 4-cascade CSM shadows, IBL cubemaps, clustered forward+,
Lumen GI and VXGI (both implemented), TAA, SSAO/GTAO, SSGI, SSR, volumetric
fog, god rays, atmosphere, particle system, impostor LOD3. Editor is fully
unified onto this pipeline post-Fix-27 (April 2026).
Terrain — astraweave-terrain with climate field, Whittaker biome lookup,
per-biome parameter blending, regional archetype variation, 32-layer material
pipeline driven by MaterialLibrary in astraweave-render.
Visual editor — tools/aw_editor 224K LoC, 41 panel types, 49 panel files,
~9,397 test annotations. Editor depends on astraweave-render non-optionally
post-Fix-27.
Physics — astraweave-physics wrapping Rapier3D 0.22.
PhysicsWorld + CharacterController. Send + Sync.
Foundation verification — Miri (977 tests across ecs, math, core,
sdk with zero undefined behavior) + Kani proofs (71+ harnesses across
safety-critical crates).
Veilweaver — the game project this engine is built for. Vertical slice
shipped February 2026; full game development is the next 12-18 months of
primary effort.
Editor Multi-Tool Architecture Campaign (Sub-phase 3) — Mediator Brush
diagnostic, Round 8 closure. §7.7 wrapped-component resource identity trap
surfaced at four layers; Real-Fix.A/B/C landed, Real-Fix.D pending. See
aw_editor.md §1.
Editor Behavioral Correctness Audit remediation — 37 fixes across 47
commits shipped; per-audit open items still pending Andrew-gate. See
docs/current/EDITOR_BEHAVIORAL_CORRECTNESS_AUDIT.md .
Architecture trace campaign — 13 of N subsystems traced. Treated as part
of the production contract by CLAUDE.md. Adding more traces as subsystems
cross the non-trivial-surface threshold.
Per ARCHITECTURE_MAP.md §5.1, the workspace carries ~200K LoC of
in-design-but-tested surface — code that passes its own tests but has zero
production callers. This is the signature of breadth-first AI-augmented
development, not a flaw. Each item is documented honestly as awaiting a wiring
decision:
Subsystem LoC Status note
astraweave-fluids~84.5K Only examples/fluids_demo consumes it. Five parallel solver surfaces, no game-loop deps. Q12 in §14.
astraweave-memory~11K Zero in-engine production consumers. Q11 in §14.
astraweave-coordination~5.3K Zero workspace consumers; 3 commented-out pub mod declarations.
Advanced GOAP (astraweave-ai/src/goap/) ~16.7K Feature planner_advanced; parallel to canonical GOAP. Q2 in §14.
LLM Production Hardening ~15K Rate limiting / circuit breakers / A/B routing / retry / telemetry / ToolGuard / 4-tier fallback — runtime path bypasses entirely. Q4 in §14.
RAG composite (astraweave-rag + embeddings + context) ~12.3K Held as field by 5 dormant consumer crates. HNSW advertised; actual is linear scan.
Dialogue LLM layer ~2.9K 60% of astraweave-dialogue. Basic DialogueGraph is production-wired; LLM layer is not.
astraweave-net-ecs ECS Plugin layermedium Working tests, no production consumer; declared-but-unused dep in astraweave-stress-test.
astraweave-persistence-ecs Plugin layermedium auto_save_system body is comment-only TODO; replay_system advances tick but never applies events. Q18 in §14.
Plus dormant scaffolding (TODO-only bodies), orphan source files, declared-but-unused
Cargo deps, dormant feature flags, and the aspirational documentation tree from
commit 28bc94f21. Full taxonomy in ARCHITECTURE_MAP.md §5.
The engine itself. There is no v1.0 timeline . The engine ships when Veilweaver
ships, which is a 12–18 month horizon as of this update (2026-05-15). API
stability guarantees do not exist; consumers should pin to a commit.
ARCHITECTURE_MAP.md §14 documents 23 cross-cutting open questions surfaced by
the trace campaign — decisional items that affect more than one subsystem and
require explicit Andrew-gate decisions. Highlights:
Q1 — Long-term plan for legacy astraweave-core::World (dual-World coexistence).
Q2 — Two GOAP implementations: consolidation roadmap?
Q3 — Runtime LLM model default (currently phi3:medium despite Qwen3 doc-comments).
Q4 — Production-hardening surface bypassed by runtime AIArbiter path.
Q5 — §7.7 wrapped-component resource identity trap, preventive instrumentation.
Q11, Q12 — Memory pipeline and Fluids dormancy: production-wire, prune, or rebrand?
Q17 — Standalone server HMAC vs. XOR sign16 mismatch.
Q20 — Editor god-struct refactor (EditorApp 123 fields).
The list is exhaustive; the architecture map is the canonical reference.