neurodoom 0.6.7

Deterministic no_std Doom engine with semantic and depth perception buffers for AI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
//! AI multiplayer demo — 24 reactive bots fight in E1M1 with no monsters.
//!
//! Run: cargo run --release --example ai_multiplayer
//!
//! Showcases:
//!   - Many peers controlling separate player entities in one engine.
//!   - Per-player semantic + depth perception (rendered from each AI's POV).
//!   - A trivial "perception → action" policy that uses ONLY the semantic
//!     and depth buffers — no map data, no entity list. Same input shape a
//!     learned model (e.g. SauerkrautLM-Doom-MultiVec) would consume.
//!
//! Window layout: 6×4 grid (1920×800 at 1:1, fits a 1080p screen). E1M1 only
//! has ~4 player starts, so spawn positions are reused with random ±jitter
//! to avoid stacking.
//!
//! Controls:
//!   Tab  — cycle view (normal → semantic → depth) for all cells
//!   R    — respawn all bots at fresh random player starts
//!   Esc  — quit
//!
//! Optional args: <map>           e.g. `cargo run --example ai_multiplayer -- E1M3`
//! Env:           DOOM_WAD=path   override the WAD path

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};

// --- Error type ---------------------------------------------------------

#[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;

/// Re-render each bot's POV every Nth tick (staggered across bots so the
/// per-tick cost is `NUM_BOTS / RENDER_STAGGER`). `decide` still runs every
/// tick on the most recent perception. With 35 ticks/sec and stagger=2, each
/// cell refreshes ~17×/sec — barely noticeable but cuts render cost in half.
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());

    // new_with_rules() loads geometry/textures but does NOT spawn map things,
    // so we get a clean slate with zero monsters and zero items.
    let mut engine = DoomEngine::new_with_rules(&wad_data, &map_name, ClassicDoomRules)
        .map_err(DemoError::Engine)?;

    // Mark every "secret" sector as already discovered (special 9 → 0).
    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;
        }
    }

    // Collect candidate spawn positions: classic player starts + deathmatch starts.
    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());

    // Spawn NUM_BOTS bots, sampling start positions (with replacement when
    // there are fewer starts than bots — typical for E1M1 with ~4 starts and
    // 24 bots). Jitter is applied and validated against map geometry so bots
    // don't land inside a wall or off the map.
    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; // 0 = normal, 1 = semantic, 2 = depth

    let mut bot_state = [BotState::default(); NUM_BOTS];

    // Reusable per-bot scratch buffers — avoid allocating each frame.
    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];
        }

        // 1. For each bot: render its POV (staggered to amortize cost), then
        //    decide. Dead bots emit no input — their death animation is
        //    advanced by the engine via tick_entities.
        let mut actions: [(PeerId, PlayerAction); NUM_BOTS] =
            [(PeerId(0), PlayerAction::default()); NUM_BOTS];
        let tick = engine.world().tick;
        for i in 0..NUM_BOTS {
            // Each bot is re-rendered every RENDER_STAGGER ticks; stagger
            // offset is `i % RENDER_STAGGER` so the load is spread across ticks.
            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);
        }

        // 2. Advance simulation only (we render per-bot above; no auto-render needed).
        engine.simulate(&actions, &[]);

        // 3. Compose grid framebuffer for display. Dead bots' cells switch
        //    to the depth view so they're visually distinct from the living.
        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(())
}

// --- AI policy ----------------------------------------------------------

#[derive(Default, Clone, Copy)]
struct BotState {
    /// Current wander turn direction (in `angle_turn` units).
    wander_turn: i16,
    /// Ticks remaining before re-randomizing the wander direction.
    wander_ttl: u8,
    /// Last observed (x, y) — used to detect "stuck against a wall".
    last_x: Fixed,
    last_y: Fixed,
    /// Initialized flag for last_x/last_y.
    has_last_pos: bool,
    /// Consecutive ticks where forward intent did not produce movement.
    stuck_ticks: u8,
    /// While > 0, the bot executes an unstuck maneuver (sharp turn + back up).
    unstuck_ttl: u8,
    /// Turn direction while unstucking (`+`/`-` angle_turn units).
    unstuck_turn: i16,
}

/// A trivial "perception → action" policy that uses ONLY the semantic and
/// depth buffers — no map awareness, no entity list. Same shape of input a
/// learned controller would consume.
///
/// Strategy:
///   1. Hunt: scan the semantic buffer for `Player` pixels (the other bot).
///      If found, proportional-aim onto the closest one and fire when
///      roughly centered.
///   2. Wander: if no enemy is visible, walk forward. If the depth buffer
///      shows a wall too close ahead, turn toward whichever side has more
///      open space. Otherwise apply a small random turn that re-rolls every
///      few ticks so the bot explores instead of orbiting one spot.
///   3. Unstuck: if commanded forward but the bot's actual position barely
///      changed for several ticks, commit to a sharp turn + back up for ~30
///      ticks until it can move again.
///   4. Doors: if a `Door`-class pixel is close in the central strip, press
///      Use to open it.
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;

    // --- Stuck detection: did we move since last tick?
    // 1 map unit = FRACUNIT in fixed-point. "Barely moved" = under ~2 units.
    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 currently in unstuck maneuver, just execute it.
    if state.unstuck_ttl > 0 {
        state.unstuck_ttl -= 1;
        if !moved {
            // Still pinned — keep counter from running out too quickly.
            state.unstuck_ttl = state.unstuck_ttl.max(8);
        }
        let mut cmd = PlayerAction::default();
        cmd.angle_turn = state.unstuck_turn;
        // Back up + try to slip sideways to escape corners.
        cmd.forward_move = -15;
        cmd.side_move = if state.unstuck_turn > 0 { 15 } else { -15 };
        // Reset other wander timers so behavior recovers cleanly.
        state.wander_ttl = 0;
        return cmd;
    }

    // Search the central horizontal band for the nearest Player pixel.
    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 {
        // Hunting mode — proportional aim + advance + fire when centered.
        let dx = col - center_x;
        // angle_turn=512 ≈ 2.8°/tick; cap so we don't spin past the target.
        cmd.angle_turn = ((-dx) * 24).clamp(-1500, 1500) as i16;
        // Close distance unless very near.
        cmd.forward_move = if best_depth > 200 * FRACUNIT { 25 } else { 8 };
        if dx.abs() < 6 {
            cmd.buttons |= Button::Attack;
        }
        // Reset wander state so we don't immediately start spinning when we
        // briefly lose the target.
        state.wander_ttl = 0;
    } else {
        // No enemy in sight — wander.
        // Average depth over a small horizontal swath in the floor band so
        // a single skinny pillar doesn't fool us.
        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 {
            // Wall ahead — turn toward whichever side is more open and slow down.
            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 {
                // Small random turn, re-rolled every ~10–25 ticks.
                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;
        }

        // Periodically check for a door right in front — press Use if so.
        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;
                    }
                }
            }
        }
    }

    // --- Stuck escalation: trying to move forward but not moving → unstuck.
    if cmd.forward_move > 0 && !moved {
        state.stuck_ticks = state.stuck_ticks.saturating_add(1);
        if state.stuck_ticks >= 6 {
            // Commit to a 30-tick escape: sharp turn + reverse + sidestep.
            state.unstuck_ttl = 30;
            // Pick a turn direction: prefer the side with more open depth, but
            // randomize on ties so two stuck bots don't oscillate together.
            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
}

/// Average depth over `2*half_w + 1` columns at fixed row `y`, ignoring zeros.
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 }
}

// --- Engine helpers -----------------------------------------------------

/// Spawn a player-controlled entity at the given map thing position.
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)
}

/// Teleport an existing player entity to a new spawn position and fully
/// reset it (health, flags, spawn state) so it works on dead bots too.
fn respawn_at(engine: &mut DoomEngine<ClassicDoomRules>, eid: EntityId, thing: &MapThing) {
    engine.respawn(eid, Pose::from_map_thing(thing));
}

/// Give a bot a usable loadout (skip plasma/BFG — sprites missing in shareware).
fn arm_bot(engine: &mut DoomEngine<ClassicDoomRules>, eid: EntityId) {
    if let Some(ps) = engine.world_mut().player_state_mut(eid) {
        // [Fist, Pistol, Shotgun, Chaingun, Rocket, Plasma, BFG, Chainsaw, SSG]
        ps.weapon_owned = [true, true, true, true, true, false, false, true, false];
        ps.ammo = [200, 50, 0, 50]; // bullets, shells, cells, rockets
        ps.ready_weapon = WeaponType::Shotgun;
        ps.pending_weapon = WeaponType::Shotgun;
    }
}

// --- Display composition ------------------------------------------------

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;
        // Dead bots always render in depth-view, regardless of the global
        // view_mode toggle — makes corpses immediately visible in the grid.
        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
                }
            };
        }
    }
}

// Semantic/depth rendering + LCG RNG come from `common.rs`, shared with
// `demo.rs` (see top-of-file imports).

/// Pick `count` indices in `0..n`. Distinct when `n >= count`; otherwise
/// samples with replacement (necessary when there are fewer map starts than
/// bots — relies on `jitter_thing` to keep the bots from stacking).
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()
    }
}

/// Try several random ±jitters around a spawn position, returning the first
/// that is collision-free against the map (so bots reusing the same start
/// don't stack but also don't end up inside walls or off the map). Falls
/// back to the original position if no jitter succeeds in `MAX_TRIES`.
fn valid_jitter(
    map: &neurodoom::map::MapData,
    thing: &MapThing,
    rng: &mut Lcg,
) -> MapThing {
    const MAX_TRIES: u32 = 24;
    // MT_PLAYER radius (16 << FRACBITS).
    let radius = EntityType(0).info().unwrap().radius;
    for _ in 0..MAX_TRIES {
        let dx = (rng.next() as i32 % 97) - 48; // ±48
        let dy = (rng.next() as i32 % 97) - 48;
        let da = (rng.next() as i32 % 90) - 45; // ±45° facing
        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
}