use core::fmt;
use std::process::ExitCode;
use minifb::{Key, Window, WindowOptions};
#[path = "common.rs"]
mod common;
use common::{Lcg, depth_to_rgb, seed_from_clock};
use neurodoom::classic::ClassicDoomRules;
use neurodoom::engine::{DoomEngine, DoomError};
use neurodoom::map::MapThing;
use neurodoom::math::*;
use neurodoom::render::SemanticClass;
use neurodoom::rules::PlayerAction;
use neurodoom::types::{doomednum, Button, WeaponType};
use neurodoom::world::{EntityId, EntityType, PeerId, Pose};
#[derive(Debug)]
enum DemoError {
WadRead { path: String, err: std::io::Error },
Engine(DoomError),
NoStarts { map: String },
NoPlayerInfo,
Window(minifb::Error),
Update(minifb::Error),
}
impl fmt::Display for DemoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::WadRead { path, err } => write!(f, "failed to read WAD at {path}: {err}"),
Self::Engine(e) => write!(f, "failed to init engine: {e}"),
Self::NoStarts { map } => write!(f, "no player/deathmatch starts on {map}"),
Self::NoPlayerInfo => write!(f, "MT_PLAYER missing from MOBJINFO table"),
Self::Window(e) => write!(f, "failed to create window: {e}"),
Self::Update(e) => write!(f, "framebuffer update failed: {e}"),
}
}
}
impl std::error::Error for DemoError {}
const NUM_BOTS: usize = 24;
const GRID_COLS: usize = 6;
const GRID_ROWS: usize = 4;
const COMPOSITE_W: usize = SCREENWIDTH * GRID_COLS;
const COMPOSITE_H: usize = SCREENHEIGHT * GRID_ROWS;
const RENDER_STAGGER: u32 = 2;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<(), DemoError> {
let wad_path =
std::env::var("DOOM_WAD").unwrap_or_else(|_| "doom1.wad".to_string());
let wad_data = std::fs::read(&wad_path).map_err(|err| DemoError::WadRead {
path: wad_path.clone(),
err,
})?;
let map_name = std::env::args().nth(1).unwrap_or_else(|| "E1M1".to_string());
let mut engine = DoomEngine::new_with_rules(&wad_data, &map_name, ClassicDoomRules)
.map_err(DemoError::Engine)?;
for s in &mut engine.map_mut().sectors {
if s.special == 9 {
s.special = 0;
}
}
for ws in &mut engine.world_mut().sectors {
if ws.special == 9 {
ws.special = 0;
}
}
let starts: Vec<MapThing> = engine
.map()
.things
.iter()
.copied()
.filter(|t| {
(t.type_num >= doomednum::PLAYER1_START && t.type_num <= doomednum::PLAYER4_START)
|| t.type_num == doomednum::DEATHMATCH_START
})
.collect();
if starts.is_empty() {
return Err(DemoError::NoStarts { map: map_name });
}
let mut rng = Lcg::new(seed_from_clock());
let spawn_idxs = sample_n(&mut rng, starts.len(), NUM_BOTS);
let mut bot_ids = [EntityId(0); NUM_BOTS];
for (i, &si) in spawn_idxs.iter().enumerate() {
let start = starts.get(si).ok_or(DemoError::NoStarts {
map: map_name.clone(),
})?;
let placed = valid_jitter(engine.map(), start, &mut rng);
bot_ids[i] = spawn_ai_player(&mut engine, &placed, PeerId(i as u32))?;
arm_bot(&mut engine, bot_ids[i]);
}
eprintln!("Spawned {NUM_BOTS} bots across {} start positions", starts.len());
let mut window = Window::new(
"neurodoom ai_multiplayer — Tab: view R: respawn Esc: quit",
COMPOSITE_W,
COMPOSITE_H,
WindowOptions::default(),
)
.map_err(DemoError::Window)?;
window.set_target_fps(35);
let mut fb = vec![0u32; COMPOSITE_W * COMPOSITE_H];
let mut view_mode: u8 = 0;
let mut bot_state = [BotState::default(); NUM_BOTS];
let n = SCREENWIDTH * SCREENHEIGHT;
let mut sem: [Vec<u8>; NUM_BOTS] = std::array::from_fn(|_| vec![0u8; n]);
let mut dep: [Vec<Fixed>; NUM_BOTS] = std::array::from_fn(|_| vec![0i32; n]);
let mut rgba: [Vec<u8>; NUM_BOTS] = std::array::from_fn(|_| vec![0u8; n * 4]);
while window.is_open() && !window.is_key_down(Key::Escape) {
if window.is_key_pressed(Key::Tab, minifb::KeyRepeat::No) {
view_mode = (view_mode + 1) % 3;
}
if window.is_key_pressed(Key::R, minifb::KeyRepeat::No) {
let new_idxs = sample_n(&mut rng, starts.len(), NUM_BOTS);
for (i, &si) in new_idxs.iter().enumerate() {
let Some(start) = starts.get(si) else { continue };
let Some(&bid) = bot_ids.get(i) else { continue };
let placed = valid_jitter(engine.map(), start, &mut rng);
respawn_at(&mut engine, bid, &placed);
arm_bot(&mut engine, bid);
}
bot_state = [BotState::default(); NUM_BOTS];
}
let mut actions: [(PeerId, PlayerAction); NUM_BOTS] =
[(PeerId(0), PlayerAction::default()); NUM_BOTS];
let tick = engine.world().tick;
for i in 0..NUM_BOTS {
if (tick % RENDER_STAGGER) as usize == i % RENDER_STAGGER as usize {
engine.render_for(PeerId(i as u32));
sem[i].copy_from_slice(engine.semantic_buffer());
dep[i].copy_from_slice(engine.depth_buffer());
rgba[i].copy_from_slice(engine.framebuffer());
}
let bot_e = engine.world().get(bot_ids[i]);
let alive = bot_e.is_some_and(|e| e.health > 0);
let act = if let (true, Some(e)) = (alive, bot_e) {
decide(&sem[i], &dep[i], e.x, e.y, tick, &mut bot_state[i], &mut rng)
} else {
PlayerAction::default()
};
actions[i] = (PeerId(i as u32), act);
}
engine.simulate(&actions, &[]);
let alive_mask: [bool; NUM_BOTS] = std::array::from_fn(|i| {
engine.world().get(bot_ids[i]).is_some_and(|e| e.health > 0)
});
compose_grid(&mut fb, view_mode, &alive_mask, &rgba, &sem, &dep);
window
.update_with_buffer(&fb, COMPOSITE_W, COMPOSITE_H)
.map_err(DemoError::Update)?;
}
Ok(())
}
#[derive(Default, Clone, Copy)]
struct BotState {
wander_turn: i16,
wander_ttl: u8,
last_x: Fixed,
last_y: Fixed,
has_last_pos: bool,
stuck_ticks: u8,
unstuck_ttl: u8,
unstuck_turn: i16,
}
fn decide(
semantic: &[u8],
depth: &[Fixed],
pos_x: Fixed,
pos_y: Fixed,
tick: u32,
state: &mut BotState,
rng: &mut Lcg,
) -> PlayerAction {
let w = SCREENWIDTH;
let h = SCREENHEIGHT;
let center_x = w as i32 / 2;
let move_threshold: Fixed = 2 * FRACUNIT;
let moved = if state.has_last_pos {
let dx = (pos_x - state.last_x).abs();
let dy = (pos_y - state.last_y).abs();
dx + dy > move_threshold
} else {
true
};
state.last_x = pos_x;
state.last_y = pos_y;
state.has_last_pos = true;
if state.unstuck_ttl > 0 {
state.unstuck_ttl -= 1;
if !moved {
state.unstuck_ttl = state.unstuck_ttl.max(8);
}
let mut cmd = PlayerAction::default();
cmd.angle_turn = state.unstuck_turn;
cmd.forward_move = -15;
cmd.side_move = if state.unstuck_turn > 0 { 15 } else { -15 };
state.wander_ttl = 0;
return cmd;
}
let player_class = SemanticClass::Player as u8;
let band_top = h / 4;
let band_bot = (3 * h) / 4;
let mut best_col: Option<i32> = None;
let mut best_depth: Fixed = Fixed::MAX;
for y in band_top..band_bot {
let row = y * w;
for x in 0..w {
let idx = row + x;
if semantic[idx] == player_class {
let d = depth[idx];
if d > 0 && d < best_depth {
best_depth = d;
best_col = Some(x as i32);
}
}
}
}
let mut cmd = PlayerAction::default();
if let Some(col) = best_col {
let dx = col - center_x;
cmd.angle_turn = ((-dx) * 24).clamp(-1500, 1500) as i16;
cmd.forward_move = if best_depth > 200 * FRACUNIT { 25 } else { 8 };
if dx.abs() < 6 {
cmd.buttons |= Button::Attack;
}
state.wander_ttl = 0;
} else {
let look_y = h / 2;
let center_d = avg_depth(depth, look_y, center_x as usize, 12);
let look_left = avg_depth(depth, look_y, w / 4, 12);
let look_right = avg_depth(depth, look_y, (3 * w) / 4, 12);
let too_close = 96 * FRACUNIT;
if center_d > 0 && center_d < too_close {
cmd.angle_turn = if look_left > look_right { 768 } else { -768 };
cmd.forward_move = 4;
} else {
cmd.forward_move = 25;
if state.wander_ttl == 0 {
let r = rng.next();
state.wander_turn = (((r as i32) % 9) - 4) as i16 * 96;
state.wander_ttl = 10 + ((r >> 8) as u8 % 16);
}
cmd.angle_turn = state.wander_turn;
state.wander_ttl -= 1;
}
if tick.is_multiple_of(8) {
let door_class = SemanticClass::Door as u8;
let cx = w / 2;
let strip_lo = cx.saturating_sub(40);
let strip_hi = (cx + 40).min(w);
let door_range = 96 * FRACUNIT;
'outer: for y in band_top..band_bot {
let row = y * w;
for x in strip_lo..strip_hi {
let i = row + x;
if semantic[i] == door_class && depth[i] > 0 && depth[i] < door_range {
cmd.buttons |= Button::Use;
break 'outer;
}
}
}
}
}
if cmd.forward_move > 0 && !moved {
state.stuck_ticks = state.stuck_ticks.saturating_add(1);
if state.stuck_ticks >= 6 {
state.unstuck_ttl = 30;
let look_left = avg_depth(depth, h / 2, w / 4, 12);
let look_right = avg_depth(depth, h / 2, (3 * w) / 4, 12);
state.unstuck_turn = if look_left > look_right + 4 * FRACUNIT {
1024
} else if look_right > look_left + 4 * FRACUNIT {
-1024
} else if rng.next() & 1 == 0 {
1024
} else {
-1024
};
state.stuck_ticks = 0;
}
} else {
state.stuck_ticks = 0;
}
cmd
}
fn avg_depth(depth: &[Fixed], y: usize, x: usize, half_w: usize) -> Fixed {
let w = SCREENWIDTH;
let lo = x.saturating_sub(half_w);
let hi = (x + half_w + 1).min(w);
let row = y * w;
let mut sum: i64 = 0;
let mut n: i64 = 0;
for xi in lo..hi {
let d = depth[row + xi];
if d > 0 {
sum += d as i64;
n += 1;
}
}
if n == 0 { 0 } else { (sum / n) as Fixed }
}
fn spawn_ai_player(
engine: &mut DoomEngine<ClassicDoomRules>,
thing: &MapThing,
peer: PeerId,
) -> Result<EntityId, DemoError> {
let pose = Pose::from_map_thing(thing);
engine.spawn_player(peer, pose).ok_or(DemoError::NoPlayerInfo)
}
fn respawn_at(engine: &mut DoomEngine<ClassicDoomRules>, eid: EntityId, thing: &MapThing) {
engine.respawn(eid, Pose::from_map_thing(thing));
}
fn arm_bot(engine: &mut DoomEngine<ClassicDoomRules>, eid: EntityId) {
if let Some(ps) = engine.world_mut().player_state_mut(eid) {
ps.weapon_owned = [true, true, true, true, true, false, false, true, false];
ps.ammo = [200, 50, 0, 50]; ps.ready_weapon = WeaponType::Shotgun;
ps.pending_weapon = WeaponType::Shotgun;
}
}
fn compose_grid(
fb: &mut [u32],
view_mode: u8,
alive: &[bool; NUM_BOTS],
rgba: &[Vec<u8>; NUM_BOTS],
sem: &[Vec<u8>; NUM_BOTS],
dep: &[Vec<Fixed>; NUM_BOTS],
) {
for i in 0..NUM_BOTS {
let cell_col = i % GRID_COLS;
let cell_row = i / GRID_COLS;
let x_off = cell_col * SCREENWIDTH;
let y_off = cell_row * SCREENHEIGHT;
let cell_mode = if alive[i] { view_mode } else { 2 };
fill_cell(fb, x_off, y_off, cell_mode, &rgba[i], &sem[i], &dep[i]);
}
}
fn fill_cell(
fb: &mut [u32],
x_off: usize,
y_off: usize,
view_mode: u8,
rgba: &[u8],
sem: &[u8],
dep: &[Fixed],
) {
for y in 0..SCREENHEIGHT {
for x in 0..SCREENWIDTH {
let src = y * SCREENWIDTH + x;
let dst = (y_off + y) * COMPOSITE_W + x_off + x;
fb[dst] = match view_mode {
1 => SemanticClass::from_u8(sem[src]).to_rgb(),
2 => depth_to_rgb(dep[src]),
_ => {
let base = src * 4;
let r = rgba[base] as u32;
let g = rgba[base + 1] as u32;
let b = rgba[base + 2] as u32;
(r << 16) | (g << 8) | b
}
};
}
}
}
fn sample_n(rng: &mut Lcg, n: usize, count: usize) -> Vec<usize> {
assert!(n > 0, "sample_n: empty pool");
if n >= count {
let mut pool: Vec<usize> = (0..n).collect();
let mut out = Vec::with_capacity(count);
for _ in 0..count {
let i = rng.next_in(pool.len());
out.push(pool.swap_remove(i));
}
out
} else {
(0..count).map(|_| rng.next_in(n)).collect()
}
}
fn valid_jitter(
map: &neurodoom::map::MapData,
thing: &MapThing,
rng: &mut Lcg,
) -> MapThing {
const MAX_TRIES: u32 = 24;
let radius = EntityType(0).info().unwrap().radius;
for _ in 0..MAX_TRIES {
let dx = (rng.next() as i32 % 97) - 48; let dy = (rng.next() as i32 % 97) - 48;
let da = (rng.next() as i32 % 90) - 45; let nx = thing.x.saturating_add(dx as i16);
let ny = thing.y.saturating_add(dy as i16);
let fx = (nx as Fixed) << FRACBITS;
let fy = (ny as Fixed) << FRACBITS;
if neurodoom::physics::check_position(map, radius, fx, fy).ok {
return MapThing {
x: nx,
y: ny,
angle: thing.angle.wrapping_add(da as i16),
type_num: thing.type_num,
options: thing.options,
};
}
}
*thing
}