Skip to content

Bots detour around physics/animation props - #2197

Merged
sunzenshen merged 8 commits into
NeotokyoRebuild:masterfrom
sunzenshen:bot-avoid-moved-props
Oct 10, 2026
Merged

sunzenshen merged 8 commits into
NeotokyoRebuild:masterfrom
sunzenshen:bot-avoid-moved-props

Conversation

@sunzenshen

@sunzenshen sunzenshen commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Description

If a bot meets a physics prop in its path and the prop is not trivially pushable, it maps out a prop-aware grid and routes past the obstacle. Marking NAV_MESH_AVOID or cutting around problematic physics props in the navmesh is still recommended, so bots don't have to route around them regularly. This change lets maps contain physics props without trapping bots when props move out of their expected position. The same look ahead also finds glass and other breakables in the bot's way, in place of the breakable check's own hull trace.

Dependencies and status

Needs (code PRs this one depends on):

  • None.

Helped by:

  • None.

Pending before review:

Navmesh PRs that rely on this

None of the open navmesh PRs needs this: each beats master without it. It acts on props the meshes deliberately do not cut out because they move: the two office chairs on ntre_dusk_ctg and ntre_dawn_ctg (NeotokyoRebuild/neoAssets#150 and #151) and the hanging robobags on ntre_ghost_ctg (NeotokyoRebuild/neoAssets#163).

Problem

A navmesh is generated without regard to physics prop entities, so it cannot know where a prop that moves will end up. This applies to:

  • physics props (prop_physics*, func_physbox*), which players push and shoot about;
  • the bone followers that carry an animated prop_dynamic's collision.

A bot's path runs straight through such a prop. The stock PathFollower::Avoid probes 50 u ahead with two boxes, one on each side of the bot's center line, and leans away from the side that hits. A prop wider than the bot hits both, so the bot walks into it until the stuck monitor fires.

Problem areas on official maps:

  • ntre_ghost_ctg: rows of hanging robobag physics props, 86 u tall.
  • ntre_dusk_ctg and ntre_dawn_ctg office chairs, and the scooter on ntre_redlight_ctg: bots walk into the prop or climb onto it.
  • The rabbitfrog VTOL (prop_dynamic, collision on 7 bone followers) lands on the capture zone of dusk and dawn in the first 10 s of every round and parks there. Decom, saitama and rise have the same vehicle.

What this PR does

Adds CNEOBotPathObstacles (neo/bot/neo_bot_path_obstacles.{h,cpp}), a member of PathFollower under #ifdef NEO (m_pathObstacles). PathFollower::Update runs it before ledge climbing and Avoid. It owns the look ahead every 0.3 s (steps 1, 2 and 7) and a CNEOBotPropDetour (neo/bot/neo_bot_prop_detour.{h,cpp}: the grid search and the detour with its replans, steps 3-6); both use the prop query in neo/bot/neo_bot_obstacle_props.{h,cpp}.

  1. Look for props in the way.
    • Every 0.3 s the bot looks 384 u along its path (ground segments only): 256 u for props in the way, and 128 u more for where a detour may rejoin it.
    • Around that stretch it looks for props that are movable (the classes above), solid to player movement (debris is not), bigger than 16 x 16 x 40 u, and in sight (no world geometry between the bot's eyes and the prop's middle).
    • The query walks the spatial partition and keeps only such props, so cans, weapons and triggers in the same box cannot crowd them out.
    • A moving prop is also taken where it will be in 1 s, so one coming down is seen before it lands.
    • Static and fixed props are not considered: they are part of the map the mesh was built from.
  2. Does the bot's body meet one? A box the bot's width, from a step above the floor to standing height, is swept along the first 256 u and clipped against each prop's own collision model (ClipRayToEntity), both where the prop is and where it will be.
  3. Find a way around on the mesh.
    • If the body meets a prop, a grid of 16 u cells is laid over the path up to 32 u past the last prop in the way, the props it crosses, and a 48 u margin. Every prop on that grid is looked for again.
    • A region more than 96 cells a side gets cells 1.5 times wider until it fits.
    • A cell is free when a nav area lies under it, close in height to the path, and not blocked for the bot's team. The area is looked up only when the search or a prop first reaches the cell.
    • A cell is occupied when the body standing there would touch a prop. Only the cells under a prop's footprint are tested against it.
    • Neighboring cells connect only within one area or across a connection between two areas, and not up more than a step, so a detour cannot cut through a wall between areas.
    • A diagonal step also needs both cells beside it on the mesh and clear of props the bot cannot shove aside (step 4), so two props that touch at a corner leave no gap.
    • A* (8-connected) runs from the bot to the rejoin point. A cell occupied by such a prop costs as much as 1000 free ones.
    • A route may start through occupied cells only within a hull width of the bot's feet, so a bot under or against a prop steps out of it. A route that crosses one further on is no detour: the bot searches wider (step 5), and failing that keeps its path.
  4. Shove light props, as a player does.
    • A prop that physics moves freely (motion on, not hung on a rope or hinge) and that weighs at most 200 gives way when a player walks into it. Examples: office chairs, bins, carts. Not: crates, the scooter, the hung robobags, or an animated prop's bone followers.
    • Light-prop cells cost 4 free ones and do not block a diagonal step, so a bot walks around one chair where there is room.
    • When every prop in the way on the path is light, a route through light props past its first 3 cells (which the bot may be standing in) is no detour either. The bot keeps its path, shoves through or climbs over them as it does today, and looks again after 1 s.
    • When a heavy prop is in the way, the way around it may still pass through light ones.
  5. Search wider when there is no way around near the path.
    • A row, a U or a wall of props can run on past the ones the path crosses, so the way around its end lies outside the grid. The region may then grow to 1536 u a side (96 cells of 16 u).
    • The bot collects every prop it could reach, over a box twice that size less the near region a side, with a sight trace for each. It grows the region over every prop that touches it (each with its 48 u margin, the one that grows it least first) and searches once more.
    • One bot a server tick runs this wide search. A bot that finds the tick's search taken plans again at a random time within 0.3 s, so blocked bots take turns.
    • When the wide search finds no way around either, the bot keeps its path and looks again after 1 s.
  6. Follow it.
    • The route is straightened through free cells, under the search's corner rule, and the bot steers at each waypoint in turn.
    • While it detours, the path follower's goal is the path segment past the rejoin point, so it does not turn back for a goal the detour went around.
    • It does not climb ledges or jump gaps while detouring: no step of the detour goes up more than a step height, and the prop it goes around is no ledge to climb.
    • Every 0.3 s a detour near the path is searched again to the same rejoin point, over the same region stretched to where the bot now is, so a prop that is pushed, swings or lands is followed.
    • A detour the wide search found covers far more floor, so its search is skipped while all of these hold: as many movable props stand in its region as at the last search; all of them are asleep (physics wakes a prop that is touched or moved); the bot keeps within 16 u of the line to its next waypoint; and the last search is under 1 s old. Otherwise it is searched again on a tick no other bot has used for a wide search.
    • When there is no longer a way around, the detour is given up and the follower takes back the goal it had before, as if there had been no detour.
  7. Breakables in the way.
    • The look's partition query also keeps the solid entities a bot can break (IsAbleToBreak: func_breakable with health, func_breakable_surf, breakable props), and the same body box, swept over the first 128 u, finds the nearest one the body would meet. A hull trace along the path up to it then keeps it only if nothing else (a crate, a door, a wall; players aside) stands in front of it.
    • CNEOBotPathClearBreakable::GetBreakableInPath keeps its gates (freeze time, a threat seen recently, neo_bot_fire_weapon_allowed, a valid path) and returns that breakable, instead of tracing the bot's hull to its current path goal on every bot update.
    • While no plan looks (the bot is detouring, or waits to look again), breakables are looked for alone, on the same 0.3 s timer.
    • 128 u is more than a bot runs between two looks, so a pane is found before the bot reaches it, and near enough to knife it on arrival. With the props' 256 u, bots stopped about 240 u from a pane to shoot it.

How to test

The 16 test maps and the glass map, dev_prop_glass_glass2_ctg, are attached as ntre-proplab-maps-glass.zip (about 4.3 MB: each map's .bsp with its .nav, and dev_prop_README.txt with one line per map). They are not committed to any repo.

Each map has two 2048 u obstacle courses, one per team, joined by hallways to a ghost room. Both capture zones sit on the ghost, so the first pickup ends the round, and the teams swap courses every round. The navmesh runs under every prop, so a bot's path goes straight through them.

Download: ntre-proplab-maps-glass.zip

  1. Extract the zip into the NT;RE neo folder (the one with gameinfo.txt). It adds maps/dev_prop_*.bsp and maps/nav/dev_prop_*.nav.
  2. map dev_prop_maze_crowd_ctg, then bot_add 3 jinrai; bot_add 3 nsf, and join spectator.
  3. For movement only, with no fighting: sv_cheats 1; nb_blind 1; sv_neo_wep_dmg_modifier 0.

Try dev_prop_maze_crowd_ctg, dev_prop_chicane_pillars_ctg and dev_prop_gap48_gap30_ctg first. dev_prop_empty_single_ctg is the control. For breakables, dev_prop_glass_glass2_ctg: one course has a wall with three doorways shut by glass panes, two crates before it and a chair past it; the other a corridor shut by two panes, with office chairs between them. Bots knife or shoot the panes; the test runs used nb_blind 1 with weapon damage left on. A build without this PR shows the upstream behavior on the same maps.

Risk

Low to moderate.

  • Nothing changes until a movable prop the bot can see is in its way. In an earlier cross-map batch, 4 of the 29 maps saw no plan at all and 6 more saw fewer than 5 a match.
  • When there is no way around, or no longer one, the follower keeps or takes back its own goal and the bot carries on as it does today.
  • Any prop that physics can move counts, except those smaller than 16 x 16 x 40 u. A light one (an office chair, a cardboard box) is walked around only where there is room, and otherwise shoved or climbed as today.
  • Bots no longer climb onto a prop they are detouring around.
  • The breakable check now uses the look, which skips ladders and segments that are not on the ground; the old hull trace covered those. The prop plan also skips bots in the air; the breakable look does not. Its find is up to 0.3 s old, so the check still confirms the breakable stands.
  • Merges with Bots jump a gap link down instead of dropping into it #2198 (PathFollower::CheckProgress) without conflict.

Cost

Every 0.3 s a bot looks 384 u along its path: one spatial partition query around those segments, a sight trace to each movable prop it keeps, a body sweep against each over the first 256 u, and a body sweep against each breakable over the first 128 u.

The breakable check it replaces traced the bot's hull to its path goal on every bot update that reached it: about 6 calls and 4 hull traces a bot-second, 30-44 us a bot-second, 1.8-2.3 % of the bot update (debug build, ntre_ghost_ctg, ntre_dusk_ctg, ntre_rise_ctg, 12 bots). The look, measured every 0.25 s on those maps, was 64-83 us a bot-second, 2.7-4.6 % of the bot update. On the glass map the two together cost 89 us a bot-second, and the merged look every 0.3 s 61 us (-31 %).

Measured with the look every 0.25 s: with no prop in the way, it costs a median 14-15 us (p99 45-50 us, debug build). That is about 40 ms per 3-minute round for 12 bots on ntre_ghost_ctg and ntre_dusk_ctg. There, where detours are rare, it is two-thirds to three-quarters of this change's CPU, mostly the path walk and the partition query.

Only when a prop meets its body does the bot plan:

  • a second query over the grid;
  • a nav area lookup for each cell the search or a prop's footprint reaches (at most 96 x 96 cells);
  • one ClipRayToEntity per cell under a prop's footprint;
  • the A* search, which closes each cell at most once.

With no way around, one bot a server tick runs the wide search: one query over the box its region could grow into, a sight trace for each prop there, and a second grid. A bot whose way is through light props, or that has no way around at all, looks again after 1 s.

While a bot detours near its path, the query, the grid and the search are redone every 0.3 s. A detour the wide search found is checked every 0.3 s with at most one query that counts its region's props and whether one is awake. It is searched again only when they changed, the bot left its route, or the last search is 1 s old, and then on a tick no other bot used for a wide search.

Plan time in the debug build, measured with the look every 0.25 s, for each check that met a prop in the way or checked a detour:

  • Shipped maps, 12 bots: 25.5 ms a round between all bots, median 216 us, p99 1.1 ms.
  • Obstacle courses, the 7 maps where props move during a detour, 6 bots: 75 ms a round, median 271 us, p99 4.4 ms. A wide search has median 2.2 ms and p99 7.1 ms; a skipped wide replan's check has median 29 us.
  • Maze course: about 114 ms a round.

Results

Obstacle courses

Measured with the look every 0.25 s, before breakables used it; the maze and the glass map were rerun at 0.3 s (below).

Setup: the 16 test maps, 3 bots a team (one of each class), blind and with no weapon damage so they only move. 8-round matches in the debug build, with both arms on one build. The unit is one bot's run across its course. "Stuck" counts its OnStuck and FAIL_STUCK events in the run. Intervals are 95 % intervals from resampling whole rounds.

Against upstream, 16 maps, 2 matches a map and arm (64 matches, every one reached match end with 0 asserts). Stuck events a run, this PR minus upstream; * = 95 % interval clear of 0:

course map change
a maze: three crate walls, gaps at alternate ends dev_prop_maze_crowd_ctg -2.8*
a zigzag lane between big crates in a brush corridor dev_prop_chicane_pillars_ctg -2.4*
a crate wall with an exact 48 / 40 / 34 u gap on the line (the hull is 32 u) dev_prop_gap48_gap30_ctg (48), dev_prop_gap40_gap34_ctg (40 / 34) -1.9* / -1.1* / -0.8*
ghost's hung robobags and hung figures, in a doorway and in a row dev_prop_peek_doorplug_ctg (doorway), dev_prop_hang_street_ctg (row) -0.9*
five 2-crate bars on alternate sides of the line dev_prop_row2_row3_ctg -0.8*
a tight blob of crates dev_prop_cluster_cup_ctg -0.8*
14 crates and cabinets dropped at round start dev_prop_fall_pushwall_ctg -0.5*
a crate wall with one gap, on the line / off it dev_prop_wall0_wall448_ctg -0.4* / -0.4*
crates corner to corner on a diagonal dev_prop_diag_rotated_ctg -0.4*
5 crates across the line dev_prop_row5_wall160_ctg -0.2*
45 mixed props, 150 u apart dev_prop_asteroids_slalom_ctg -0.2*
a U of crates; a 30 u gap; brush pillars with crates in the gaps; a gap far off the line dev_prop_cluster_cup_ctg; dev_prop_gap48_gap30_ctg; dev_prop_chicane_pillars_ctg; dev_prop_wall0_wall448_ctg -0.4; -0.9; -0.2; -0.4
a wall of light bins, no gap dev_prop_litter_pushables_ctg 0.19 -> 0.00
office chairs packed wall to wall dev_prop_maze_crowd_ctg 3.24 -> 3.43, median time to cross 23.9 -> 18.5 s
dusk's chair corridor and a row of chairs; a band of light props; two crates among ~400 cans dev_prop_office_mess_ctg; dev_prop_litter_pushables_ctg 0 in both arms

No course read worse than upstream. Follow-up runs on the final code:

  • Against upstream on all 16 maps (64 matches): better on 17 courses, worse on none.
  • On the 7 maps where props move during a detour (28 matches against upstream): better on 8 courses, worse on none.
  • Maze: 7.92 -> 0 stuck a run. Hung robobags: 0.79 -> 0.17. Both intervals clear of 0.

With the look every 0.3 s and breakables in it (2 matches): the maze 0 stuck a run; the office chairs packed wall to wall 5.4, against 3.7 at 0.25 s and 4.4 upstream, a course whose figure swings by about 3 between batches.

On the course of office chairs packed wall to wall, the PR reads about 0.7 more stuck events per bot run than upstream over five batches pooled (+0.67 [-0.11, +1.44]). But there 99 % of plans keep the upstream path, and 191 of 195 stuck episodes begin before any bot on that course has detoured, so we read it as noise.

Glass map

dev_prop_glass_glass2_ctg, 6 bots, blind, weapons on, 2 matches with the merged look (13 rounds) against 4 with the old hull trace (24 rounds), every one at match end with 0 asserts: 53 panes broken against 112, none left standing when a bot gave up, no stuck event at the glass in either. From the bot's first touch to the broken pane: median 1.14 s, p90 1.55 s (old trace 1.28 s, 1.55 s). The merged look finds a pane at a median 117 u from its middle (old trace 65 u).

Shipped maps: sanity games

Measured with the look every 0.25 s, before breakables used it. 8 scenarios (ghost at two ghost spawns, dusk, dawn, redlight, tarmac, engage, ballistrade), 12 bots in normal play, 2 rounds of 3 minutes, one match a scenario and build.

build movable-prop stuck all stuck
upstream, two passes 16; 38 182; 167
this PR 0 123
  • The all-cause count swings twofold between single matches.
  • Upstream's prop-stuck events were at robobags, a hung figure's arm, the scooter on ballistrade, office chairs and a light fixture.
  • Every match reached match end with 0 asserts but one: after match end, while the next map loaded, collisionproperty.cpp (1310) fired. We have seen this assert twice before at map load on other branches.
Earlier large batches (measured on an earlier revision of this change)

These runs predate several fixes (the wide search, the corner rule for props, the light-prop rule, the route-start rule). The code then used at most 64 cells a side and 4096 cells expanded, with no wide search, light and heavy props alike, and a corner rule for cells off the mesh only. They are kept as evidence on the shipped maps.

Setup: upstream plus #2176 and #2186, both arms on the same server build, with harness logging and a test-only switch for this change (this PR has no switch). 30 bots, default weapon damage (1.485), 8 matches at a time, the two arms of a pair side by side in the same batch.

  • CTG: 2 rounds of 3 minutes, ghost pinned at one ghost spawn.
  • TDM / DM: one 5-minute round.
  • JGR: one round, juggernaut pinned.

Every match reached match end with 0 asserts and no crash. "Stuck" is OnStuck plus FAIL_STUCK move failures, without the round-start spawn crowd and without weapon scavenging. A "prop stuck" event is one whose blocker (a hull trace toward the path goal) is a movable prop. Intervals are 95 % bootstrap intervals of the paired ratio.

The meshes have no prop cut-outs. Our dusk, dawn and redlight navmeshes had cut out the floor under the office chairs, the scooter and the parked rabbitfrog. These runs use the same meshes with those cut-outs undone (on dusk and dawn, the two areas under the vehicle are back with their links). Ghost's robobags were never cut out. The other maps use the navmeshes of their open or planned neoAssets PRs.

Test cases, 52 pairs (ghost 20, dusk 10, dawn 10, redlight 12):

a match upstream this PR paired
stuck, the four maps 26.4 13.1 x0.51 [0.39, 0.66], 38 down / 13 up
... ghost (20 pairs) 38.1 16.1 x0.38 [0.26, 0.56]
... dusk (10) 21.1 14.1 x0.57 [0.28, 1.02]
... dawn (10) 18.8 11.4 x0.78 [0.46, 1.37]
... redlight (12) 17.5 8.9 x0.51 [0.27, 0.89]
prop stuck 8.35 0.38 33 down / 5 up
... robobags, ghost (totals) 302 3
... office chairs, dusk and dawn (totals) 37 7
... the scooter, redlight (totals) 67 7
... at the rabbitfrog, dusk and dawn 4.8 0.05
matches with a bot stranded on top of the scooter 5 of 12 1 of 12
FAIL_NO_PATH_EXISTS 6.75 6.21 x1.07 [0.80, 1.46]
kills 49.65 49.65 x1.00 [0.97, 1.02]
fall deaths (totals) 5 0

The rabbitfrog's landing, 40 pairs: dusk and dawn, each with the vehicle's areas cut out and restored, with the pre-round freeze cut to 3 s so bots are on the move while it comes down:

upstream this PR
stuck at the rabbitfrog, vehicle's areas restored (totals, 20 matches an arm) 170 1
... bot episodes / of 60 s or more 5 / 1 1 / 0
stuck at the rabbitfrog, vehicle's areas cut out 0 0
stuck, paired, all 40 pairs x0.74 [0.57, 0.96], 25 down / 13 up
kills 48.4 49.3

All 29 ntre_ maps, 112 pairs (up to 4 ghost or juggernaut spawns each):

a match upstream this PR paired
stuck 20.2 21.0 x0.80 [0.67, 0.98], 64 down / 43 up
bot stuck episodes 5.25 4.88 x0.88 [0.79, 0.99]
prop stuck 1.57 0.08 21 down / 6 up
FAIL_NO_PATH_EXISTS 8.85 9.65 x1.12 [0.94, 1.35]
"fell off the path" 2.03 2.07 x1.06 [0.98, 1.16]
kills 65.6 66.5 x1.03 [0.99, 1.07]
fall and hurt-volume deaths (totals) 141 115
  • The mean rises while the paired ratio falls. On ntre_terminal_jgr (24.5 -> 129) and ntre_sentinel_jgr (23 -> 47) a few bots logged most of the events, on maps where the detour hardly acts (3.5 and 0 plans a match). Maps with no detour at all read anywhere from x0.4 to x2.5 at 4 pairs: that is the noise of a single map's figure here.
  • The two maps that read worst were run again.
    • ntre_tarmac_ctg (x2.69 at 4 pairs, one bot in one match, nowhere near a detour): x0.89 [0.37, 1.86] over 12 more pairs.
    • ntre_bullet_tdm (x1.62 at 4 pairs, its extra events along the train corridor, away from the food carts the bots detour around): x0.84 [0.57, 1.25] over 16 more pairs, x1.10 [0.83, 1.47] over all 32.
  • FAIL_NO_PATH_EXISTS is ChasePath failing to reach an enemy, mostly one standing off the mesh. Over every batch of that version it rose 7 % (1741 -> 1863), each interval across 1. Nothing ties it to a detour.
  • Plan time in the debug build, over the 104 test-case matches: about 100 plans and 310 replans a match among 30 bots, a plan 353 us on average (2.6 ms at most) and a replan 214 us (2.0 ms at most), about 0.1 s of server time in a 6-minute match.
Test cases that shaped the design

Each of these obstacle-course findings drove a rule above.

  • Crates touching corner to corner. Detours led into the pinches between the corners when a diagonal step was refused only past cells off the mesh. Fixed by the corner rule in step 3.
  • A U of crates, or a crate wall whose gap is far off the line. The grid covered only the props the path crosses, so 5-10 searches a run found no way around. Fixed by the wide search in step 5.
  • A maze of crate walls, gaps at alternate ends.
    • A bot pressed against a wall stands in a prop's cells. If a route could start through any run of them, it ran through the whole wall and counted as a detour. 95 % of the course's 260 stuck events came on such a route, and the wide search never ran.
    • The wide search also collected props only 384 u around the path, short of the far gaps.
    • Fixed by letting a route start only through the prop cells by the bot's feet, and by collecting every prop the wide region could reach: stuck 4.96 -> 0.06 a run (-4.90 [-6.27, -3.69]), and 0.71 -> 0.00 on the 34 u gap.
  • Office chairs packed wall to wall (368 of them). With every prop treated as a wall, bots got stuck inside the chair grid about three times as often as upstream (4.3 stuck a run against 12.0). With light props cheap but a route through them still a detour (which turns off ledge climbing), a wall of light bins with no gap read 0.9 against upstream's 0. Upstream bots cross it by hopping onto the bins: 75 % of their position samples within 45 u of the wall were 10 u or more above the floor. Fixed by the light-prop rule in step 4.
  • Props at the grid's edge. A first plan looked for props only near the path line, though its grid reaches further, so a prop along the edge read as a gap. Now every prop on the grid is looked for. On the maze that read -3.5 [-5.6, -1.5] a run against the same build without it.
  • Search cost. The first wide search, with every cell looked up on the mesh and tested against every prop, took 316 ms of plan time a round, with a p99 plan of 35 ms. Now cells are looked up when first reached, a prop tests only the cells under its footprint, and one bot a server tick runs a wide search or a wide detour's replan. Wide detours' replans doubled the maze's plan time (173 -> 356 ms a round), so a wide detour is no longer searched again while its props rest (271 -> 158 ms a round on the maze).
Approaches tried and dropped

On the shipped maps:

  • Occupied cells as obstacles, path goal unchanged. Ghost x0.45, but dusk x2.13 [1.43, 3.36] and dawn x1.21, with office-chair stuck 4 -> 49 and 19 -> 52 (52 pairs). A detour took the bot around a chair and past its path goal without reaching it. IsAtGoal then needs the next segment to be traversable, which the chair blocks, so the follower kept the goal behind the bot. Every replan swung between the leg back to that goal and the leg on, and the bot rocked against the chairs. Fix: the goal moves to the segment past the rejoin point.
  • Rejoin goal, ledge climbing on. Ghost x0.49, dusk x0.68, dawn x0.65, but redlight x1.68 [0.80, 3.52], scooter stuck 24 -> 99. With the goal past the scooter, the line to it runs through the scooter, and Climbing took the scooter for a ledge and jumped onto it. On its sloping seat the bot is not on the ground, so it can neither walk nor jump off (5 of 12 matches). Fix: no climbing while detouring.
  • Searching afresh while detouring. Ghost x0.66, dusk x0.93, dawn x0.55, redlight x0.67 (52 pairs; office-chair and scooter stuck 46 -> 0), but ntre_engage_ctg read x1.40 [0.81, 2.54] (16 pairs), and bots still ended up on the redlight scooter. A new plan every 0.25 s ran its line from the bot straight to the goal the detour had moved past the props. That line put the rejoin point among the props or behind a wall, the search found no way around, and the detour was dropped with the follower still aimed at a goal around the corner. Bots walked into walls (engage's cardboard boxes) or climbed the scooter. Fix: replans keep the rejoin point, and a detour given up hands the follower back its own goal.
  • Steering past every prop entity that both Avoid boxes hit, with no check that the way around was clear or on the mesh: x0.87 over 5 maps, but ghost +24 % and ntre_pissalley_ctg +33 %.
  • Marking a nav area blocked when a prop stands on its center. Too coarse, and falls off the path rose as traffic moved to ghost's ladder catwalk.
  • Applying this grid detour to every solid entity. Big fixed prop_dynamics got worse (subsurface's spider tank). With static props too, most maps were x2.03 worse: they are already in the mesh.

On the obstacle courses (stuck a run unless noted):

  • The wide search on every plan. It fixed the wide obstacles too, but took four times the plan time a round (104 ms against 27). It now runs only when the search near the path finds no way.
  • The corner rule without the wide search. It lost the one-cell lane through a gap: the 48 u gap went 1.04 -> 3.17. With the wide search as well, it reads -1.9 against upstream there.
  • Light props left out of planning. It removed the light-prop losses on the courses, but in the shipped-map sanity games the office chairs came back as stuck spots: 29 movable-prop stuck events in 16 matches against upstream's 15, 20 of them at dusk's chairs.
  • Straight legs through light props, instead of waypoints from cell to cell: the bin wall still read +0.43 against upstream. The climb is what helps.
  • Any run of light-prop cells at the route's start counted as where the bot stands. A bot touching one chair took routes through the whole crowd: 5.42 against upstream's 3.56, and 9.43 once every prop on the grid was looked for. The exemption is now the route's first 3 cells.
  • The maze's escape fix without the wider prop collection. Nothing moved: the maze 4.96 -> 4.36, -0.60 [-2.41, +1.14]. 92 % of its stuck events then came where the wide search found nothing, the gaps out of its reach.
  • Skipping near detours' replans at rest too. It saved 4 ms a round over the 7 maps, but the chair crowd (+1.64 [-0.32, +3.41]), the vehicle course (+0.13) and the bin wall (+0.08) leaned the wrong way, where light props get shoved.
  • Planning 4 or 8 u clear of props, and a team memory of spots where a bot got stuck beside a prop (10 s): no clear gain.

Toolchain

  • Linux GCC Distro Native (Linux Mint 22.3, GCC 13.3.0)

Linked Issues

@sunzenshen
sunzenshen force-pushed the bot-avoid-moved-props branch from e2c925e to 292042a Compare October 3, 2026 18:55
A navmesh is generated without entities, so it cannot know where a
physics prop or an animated prop's bone followers stand. A bot's path
runs straight through such a prop, the stock Avoid probe leans away
only from props narrower than the bot, and the bot walks into the prop
until the stuck monitor fires.

CNEOBotPropDetour, a member of PathFollower under NEO, looks along the
bot's path every 0.25 s for movable props it can see and sweeps its
body against them. When one is in the way, it searches a grid of cells
over the nav mesh around the props for a route to a point on the path
past them, and the follower steers through that route's waypoints,
heading for the path segment past the props.

Light props that physics moves freely are shoved as a player does: a
bot walks around one where there is room, and keeps its path through
them when only light props are in the way. When the props run on past
the ones the path crosses, as a wall or a row does, the region grows
over them to 1536 u, one such wide search a server tick between all
bots. A detour is searched again to follow props that move; a wide
one only when its props wake or the bot leaves its route. With no way
around, the bot keeps the path it had, as before.
@sunzenshen
sunzenshen force-pushed the bot-avoid-moved-props branch from e7639e4 to e600c5a Compare October 4, 2026 08:35
@sunzenshen sunzenshen changed the title Bots steer around props that physics or an animation moves Bots steer around physics/animation props Oct 4, 2026
@sunzenshen

sunzenshen commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Demos of before/after bots avoid physics props, as well as the visualization of their physics prop detection tracelines:

(Maps created from a template where the navmesh is naive grid, and props are placed in the map without regard to that navmesh)

Chicane:

chicane-ingame.mp4
chicane-replay.mp4

Tiny gap in a wall of physics crates:

gap48-ingame.mp4
gap48-replay.mp4

Maze:

maze-ingame.mp4
maze-replay.mp4

One can verify that the navmeshes are not cut to accommodate the props with nav_edit 1:

image

@sunzenshen

Copy link
Copy Markdown
Contributor Author

Map pack of the test maps:
ntre-proplab-maps.zip

@sunzenshen
sunzenshen requested a review from sunmachine October 4, 2026 15:44
@sunzenshen sunzenshen added the Bots Related to bot players label Oct 4, 2026
@sunzenshen
sunzenshen marked this pull request as ready for review October 4, 2026 15:45
@sunzenshen sunzenshen changed the title Bots steer around physics/animation props Bots detour around physics/animation props Oct 4, 2026
The breakable check traced the bot's hull to its current path goal on
every update. The prop detour already looks along the path ahead; its
partition query now also keeps breakables, and the first one the body
would meet within the look-ahead range is what the check returns, so
the separate hull trace goes. The look runs every 0.3 s instead of
0.25 s. While detouring or waiting to retry a plan, breakables are
looked for alone on the same cadence.
At the look-ahead range of 256 u bots found glass panes about 240 u away
and stopped there to shoot them. 128 u is still beyond the distance a
bot runs between two looks, and keeps the find near where the hull
trace used to make it (65-73 u from the pane on the test maps), close
enough for the knife.
@sunzenshen

Copy link
Copy Markdown
Contributor Author

Breakables map included for testing optimization of prop+breakables detection:

ntre-proplab-maps-glass.zip

The component now looks ahead for breakables as well as props, so it
is named for that shared job: CNEOBotPathObstacles in
neo_bot_path_obstacles.{h,cpp}, PathFollower::m_pathObstacles. The
look's constants become PATH_OBSTACLE_*; the prop-only detour search
keeps its prop-detour names.
CNEOBotPathObstacles keeps the look ahead every 0.3 s and the
breakable find; CNEOBotPropDetour (neo_bot_prop_detour.{h,cpp}) holds
the grid, the route and wide searches, and the detour with its
replans. The look hands a plan request to the detour and learns when
to look again; neither reaches into the other. The prop queries both
use (body box, movable props, resting count) are in
neo_bot_obstacle_props.{h,cpp}. No behavior change.
One constant prefix per file: OBSTACLE_PROP_* for the shared prop
query, which no longer names the detour's grid; the detour defines
its grid margin and floor tolerance from the query's, and adds the
margin to the region the look hands it. The query's two optional
arguments become FindMovableProps and FindBreakables over one
enumerator that asserts a breaker for a breakables list; the cheap
health test runs before IsAbleToBreak. BodyMeetsProp is built on
BodyMeetsEntity, now in the shared file. A breakable outside the box
of the first 128 u is not swept; the breakable-only look no longer
builds lists it does not read. Comments cut to two lines.
The look clipped the body against each breakable on its own, so a pane
behind a crate, a door or a wall was reported and the bot stood still
shooting the obstacle. When the look has a breakable, the body hull is
traced along the legs up to it, and the breakable is kept only if it
is the first thing met. The trace runs only on looks that found one.
Two bots side by side at a pane each met the other first in the hull
check, so neither reported the pane and both stood there until the
stuck monitor fired. Players move, and the clear behavior already
backs off from a teammate in its line of fire.
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 4, 2026
Replace the research form of NeotokyoRebuild#2197 (build 4) with the files at
bot-avoid-moved-props be96024: the hardened detour, the
CNEOBotPathObstacles look-ahead (neo_bot_path_obstacles, renamed from
the detour's integration) and the breakable look that replaces
GetBreakableInPath's hull trace.

Harness hooks re-ported: neo_harness_propavoid (now in
NextBotPathFollow.cpp; 0 also stops the breakable look) and the
detour= field on NEO_FORENSIC_STUCK. Dropped: neo_harness_propavoid_log
(NEO_FORENSIC_PROPDETOUR plans), which the hardened code does not carry.
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 7, 2026
Replace the research form of NeotokyoRebuild#2197 (build 4) with the files at
bot-avoid-moved-props be96024: the hardened detour, the
CNEOBotPathObstacles look-ahead (neo_bot_path_obstacles, renamed from
the detour's integration) and the breakable look that replaces
GetBreakableInPath's hull trace.

Harness hooks re-ported: neo_harness_propavoid (now in
NextBotPathFollow.cpp; 0 also stops the breakable look) and the
detour= field on NEO_FORENSIC_STUCK. Dropped: neo_harness_propavoid_log
(NEO_FORENSIC_PROPDETOUR plans), which the hardened code does not carry.
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 8, 2026
Replace the research form of NeotokyoRebuild#2197 (build 4) with the files at
bot-avoid-moved-props be96024: the hardened detour, the
CNEOBotPathObstacles look-ahead (neo_bot_path_obstacles, renamed from
the detour's integration) and the breakable look that replaces
GetBreakableInPath's hull trace.

Harness hooks re-ported: neo_harness_propavoid (now in
NextBotPathFollow.cpp; 0 also stops the breakable look) and the
detour= field on NEO_FORENSIC_STUCK. Dropped: neo_harness_propavoid_log
(NEO_FORENSIC_PROPDETOUR plans), which the hardened code does not carry.
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 8, 2026
Replace the research form of NeotokyoRebuild#2197 (build 4) with the files at
bot-avoid-moved-props be96024: the hardened detour, the
CNEOBotPathObstacles look-ahead (neo_bot_path_obstacles, renamed from
the detour's integration) and the breakable look that replaces
GetBreakableInPath's hull trace.

Harness hooks re-ported: neo_harness_propavoid (now in
NextBotPathFollow.cpp; 0 also stops the breakable look) and the
detour= field on NEO_FORENSIC_STUCK. Dropped: neo_harness_propavoid_log
(NEO_FORENSIC_PROPDETOUR plans), which the hardened code does not carry.
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 8, 2026
Replace the research form of NeotokyoRebuild#2197 (build 4) with the files at
bot-avoid-moved-props be96024: the hardened detour, the
CNEOBotPathObstacles look-ahead (neo_bot_path_obstacles, renamed from
the detour's integration) and the breakable look that replaces
GetBreakableInPath's hull trace.

Harness hooks re-ported: neo_harness_propavoid (now in
NextBotPathFollow.cpp; 0 also stops the breakable look) and the
detour= field on NEO_FORENSIC_STUCK. Dropped: neo_harness_propavoid_log
(NEO_FORENSIC_PROPDETOUR plans), which the hardened code does not carry.

@sunmachine sunmachine left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some stale points that weren't described I think (see below), but otherwise this is good to go.

The GetHealth() > 0 gate on breakables (harmless), detour reset on every repath (costly but correct), per-search allocations every 0.3 s, bounded O(n²) Straighten/GrowOverProps, and the stale resumeGoal on give-up (stated as intended)

@sunzenshen
sunzenshen merged commit 5e347cf into NeotokyoRebuild:master Oct 10, 2026
9 checks passed
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 15, 2026
NeotokyoRebuild#2197's prop detour sets the path goal to the waypoint past the props
and steers around them to reach it, so the straight line from the bot
to that goal runs through the prop on purpose. The re-check would see
it as blocked and back the goal up or re-path, undoing the detour.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bots Related to bot players

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants