//! A village of 100 LLM villagers.
//!
//! Three layers:
//! render/60fps — Bevy draws, bubbles update, bodies walk
//! scheduling — wake queue + worker pool (6 in-flight Ollama calls)
//! brain — each decision is one chat call with that person's own
//! system prompt (persona + memories + perception)
mod agent;
mod llm;
mod persona;
mod sim_config;
mod world;
use bevy::diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin};
use bevy::input::mouse::{MouseMotion, MouseWheel};
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use agent::{move_bodies, update_bubbles, Names, Speech, Villager};
use llm::{deliver_talks, start_pool, LlmPool, VillagerEvent};
use world::VillageCamera;
fn main() {
App::new()
.insert_resource(ClearColor(Color::srgb(0.06, 0.07, 0.10)))
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Village — 100 LLM villagers".into(),
resolution: (1600, 950).into(),
..default()
}),
..default()
}))
.add_plugins(FrameTimeDiagnosticsPlugin::default())
.init_resource::<EventLog>()
.init_resource::<Selected>()
.add_message::<llm::VillagerEvent>()
.add_systems(
Startup,
(sim_config::setup, start_pool, world::spawn_village, agent::spawn_villagers).chain(),
)
.add_systems(
Update,
(
llm::schedule_wakes,
llm::collect_results,
deliver_talks,
move_bodies,
agent::tick_needs,
update_bubbles,
log_villager_events,
pick_villager,
hud,
camera_controls,
)
.chain(),
)
.run();
}
// ---------------------------------------------------------------------------
// Console: every conversation printed as it happens
// ---------------------------------------------------------------------------
#[derive(Resource, Default)]
pub struct EventLog {
pub lines: Vec<(f32, String)>,
}
fn log_villager_events(
time: Res<Time>,
mut events: MessageReader<VillagerEvent>,
mut log: ResMut<EventLog>,
pool: Res<LlmPool>,
query: Query<(&Speech, &Villager)>,
) {
for ev in events.read() {
match ev {
VillagerEvent::Talk { from, to, say } => {
let line = format!("{from} → {to}: {say}");
info!("{line}");
log.lines.push((time.elapsed_secs(), line));
}
VillagerEvent::Action { entity, text } => {
if let Ok((_, v)) = query.get(*entity) {
let line = format!("{} {}", v.persona.name, text);
if line.contains('→') {
info!("{line}");
log.lines.push((time.elapsed_secs(), line));
}
}
}
}
}
let _ = &pool;
let len = log.lines.len();
if len > 40 {
log.lines.drain(0..len - 40);
}
}
// ---------------------------------------------------------------------------
// HUD
// ---------------------------------------------------------------------------
#[derive(Component)]
struct HudText;
#[derive(Resource, Default)]
pub struct Selected(Option<Entity>);
fn hud(
mut commands: Commands,
diagnostics: Res<DiagnosticsStore>,
pool: Res<LlmPool>,
log: Res<EventLog>,
selected: Res<Selected>,
time: Res<Time>,
query: Query<(&Villager, &agent::Needs, &agent::Brain, &agent::Memory, &Speech)>,
mut texts: Query<&mut Text, With<HudText>>,
names: Res<Names>,
) {
let fps = diagnostics
.get(&FrameTimeDiagnosticsPlugin::FPS)
.and_then(|d| d.smoothed())
.unwrap_or(0.0);
let mut text = format!(
"VILLAGE 100 — fps {fps:.0} | llm: {} in flight (peak {}) | done {} fail {}\n\
click a villager to inspect | mouse-drag pan, wheel zoom\n\n",
pool.in_flight, pool.peak_in_flight, pool.completed, pool.failed
);
if let Some(entity) = selected.0 {
if let Ok((v, needs, brain, memory, speech)) = query.get(entity) {
let p = &v.persona;
text.push_str(&format!(
"── {} ({}) ──\nhunger {:.0} energy {:.0} social {:.0}\n\
trait: {}\nnext decision in {:.0}s{}\n\nsaid: {}\n\nmemories:\n",
p.name,
p.job,
needs.hunger,
needs.energy,
needs.social,
p.traits,
(brain.next_wake - time.elapsed_secs()).max(0.0),
if brain.in_flight { " (thinking…)" } else { "" },
if speech.text.is_empty() { "—" } else { &speech.text },
));
for m in memory.recent(8) {
text.push_str(&format!(" • {m}\n"));
}
text.push('\n');
}
}
text.push_str("── recent talk ──\n");
for (t, line) in log.lines.iter().rev().take(8).rev() {
text.push_str(&format!("[{:4.0}s] {line}\n", t / 60.0));
}
if let Ok(mut t) = texts.single_mut() {
t.0 = text;
} else {
commands.spawn((
Text::new(text),
TextFont { font_size: 13.0.into(), ..default() },
TextColor(Color::srgba(0.9, 0.95, 1.0, 0.95)),
BackgroundColor(Color::srgba(0.02, 0.03, 0.06, 0.82)),
Node {
position_type: PositionType::Absolute,
left: Val::Px(10.0),
top: Val::Px(10.0),
padding: UiRect::all(Val::Px(10.0)),
max_width: Val::Px(430.0),
..default()
},
HudText,
));
}
let _ = &names;
}
// ---------------------------------------------------------------------------
// Camera + picking
// ---------------------------------------------------------------------------
// Camera component lives in world.rs (spawned there); controls target it.
fn camera_controls(
mut query: Query<(&mut Transform, &mut Projection), With<VillageCamera>>,
mouse: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>,
mut motion: MessageReader<MouseMotion>,
mut wheel: MessageReader<MouseWheel>,
windows: Query<&Window, With<PrimaryWindow>>,
mut holding: Local<bool>,
time: Res<Time>,
) {
let Ok((mut transform, mut projection)) = query.single_mut() else {
return;
};
let dt = time.delta_secs();
let dragged: Vec2 = motion.read().map(|e| e.delta).sum();
if mouse.just_pressed(MouseButton::Right) {
*holding = true;
}
if mouse.just_released(MouseButton::Right) {
*holding = false;
}
if *holding {
// pan in screen space (camera is isometric: use its right/up)
let right = Vec3::new(transform.right().x, 0.0, transform.right().z);
let scale = projection_scale(&projection);
transform.translation -= right * dragged.x * scale * 0.9;
transform.translation += Vec3::Y * dragged.y * scale * 0.9;
transform.translation.y = 96.0; // ortho: height irrelevant visually
}
let zoom: f32 = wheel.read().map(|e| e.y).sum();
if zoom.abs() > 0.0 {
if let Projection::Orthographic(ortho) = projection.as_mut() {
ortho.scale = (ortho.scale * (1.0 - zoom * 0.12)).clamp(0.03, 0.5);
}
}
let pan = 40.0 * dt * projection_scale(&projection);
let right = Vec3::new(transform.right().x, 0.0, transform.right().z);
let right = if right.length_squared() > 1e-6 {
right.normalize()
} else {
Vec3::X
};
let fwd = Vec3::new(-right.z, 0.0, right.x);
let mut motion_v = Vec3::ZERO;
if keys.pressed(KeyCode::KeyW) || keys.pressed(KeyCode::ArrowUp) {
motion_v += fwd;
}
if keys.pressed(KeyCode::KeyS) || keys.pressed(KeyCode::ArrowDown) {
motion_v -= fwd;
}
if keys.pressed(KeyCode::KeyA) || keys.pressed(KeyCode::ArrowLeft) {
motion_v -= right;
}
if keys.pressed(KeyCode::KeyD) || keys.pressed(KeyCode::ArrowRight) {
motion_v += right;
}
if motion_v.length_squared() > 0.0 {
transform.translation += motion_v.normalize() * pan;
}
let _ = &windows;
}
fn projection_scale(projection: &Projection) -> f32 {
match projection {
Projection::Orthographic(o) => o.scale,
_ => 0.1,
}
}
fn pick_villager(
mouse: Res<ButtonInput<MouseButton>>,
windows: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform), With<VillageCamera>>,
villagers: Query<(Entity, &Transform), With<Villager>>,
mut selected: ResMut<Selected>,
) {
if !mouse.just_pressed(MouseButton::Left) {
return;
}
let Ok(window) = windows.single() else { return };
let Some(cursor) = window.cursor_position() else { return };
let Ok((cam, cam_t)) = camera.single() else { return };
let Ok(ray) = cam.viewport_to_world(cam_t, cursor) else { return };
// intersect y = 0.45 plane (villager mid-height)
let t = (0.45 - ray.origin.y) / ray.direction.y;
if !t.is_finite() || t < 0.0 {
return;
}
let point = ray.origin + *ray.direction * t;
let mut best: Option<(Entity, f32)> = None;
for (entity, t) in &villagers {
let d = Vec2::new(point.x - t.translation.x, point.z - t.translation.z).length();
if d < 1.2 && best.map(|(_, bd)| d < bd).unwrap_or(true) {
best = Some((entity, d));
}
}
selected.0 = best.map(|(e, _)| e);
}