AI Companions
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.
Companion Architecture
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
Creating a Companion
Basic Companion Setup
#![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),
))
}
}
Personality Configuration
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(),
]);
}
Companion Behaviors
Following the Player
#![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();
}
}
}
}
Combat Assistance
#![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));
}
}
}
}
}
Dialogue Interaction
#![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(),
});
}
}
}
}
}
}
Memory System
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,
}
}
Companion Commands
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;
}
}
}
}
}
}
Configuration
Companion Presets
#![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,
})
}
}
Best Practices
- 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
See Also
- AI System - Core AI architecture
- Adaptive Bosses - Enemy AI patterns
- Dialogue Systems - Conversation implementation
- Tool Validation - How actions are validated