Best Practices
This guide covers recommended practices for building production-quality games with AstraWeave, covering architecture, performance, AI integration, and maintainability.
Project Architecture
Modular Design
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
Plugin Pattern
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();
}
Dependency Injection
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
}
}
ECS Best Practices
Component Design
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
}
}
System Organization
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());
}
Query Best Practices
#![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
}
}
}
AI Integration
LLM Usage Guidelines
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
AI Architecture
#![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);
}
}
}
}
Tool Validation
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()),
}
}
}
Performance
Frame Budget Management
#![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
}
}
}
Async Operations
#![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);
}
}
}
Memory Management
#![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);
}
}
}
Error Handling
Graceful Degradation
#![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);
}
}
}
Structured Errors
#![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>;
}
Testing
Unit Testing Systems
#![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);
}
}
}
Integration Testing
#![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
}
}
Debugging
Logging
#![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);
}
}
}
}
Debug Visualization
#![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);
}
}
}
}
}
Security
Input Validation
#![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(())
}
}
Save Data Integrity
#![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)?)
}
}
Summary Checklist
- [ ] 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)
Related Documentation
- Performance Optimization - Detailed performance guide
- Configuration Reference - Configuration options
- Testing Guide - Testing strategies
- Design Patterns - Common patterns