Skip to content

More tweaks to bot ladder approaches, climbs and dismounts - #2176

Merged
sunzenshen merged 23 commits into
NeotokyoRebuild:masterfrom
sunzenshen:bot-ladder-unwanted-release-0927
Oct 12, 2026
Merged

sunzenshen merged 23 commits into
NeotokyoRebuild:masterfrom
sunzenshen:bot-ladder-unwanted-release-0927

Conversation

@sunzenshen

@sunzenshen sunzenshen commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Description

Bots reach, climb and leave ladders without hanging, bouncing back or falling off

In bot matches on the ntre_ maps, ladders were the most repeated movement failure. Bots whose path only passed a ladder hung on it. Bots that started down a ladder from its top ended back on top and tried again (95 % of the descents of ntre_saitama_ctg's elevator hatch). On a ladder whose brush is turned off the world axes (ntre_ridgeline_ctg ladder 1) bots were held in the brush at its foot for the rest of the round, and bots reaching the top of some ladders were kicked back off it. In the ladder tests below, master finishes 52 % of the climbs bots start and this branch 87 %; in normal 30-bot matches on the same maps, climbs up finish 57 % -> 83 % and descents 35 % -> 81 %.

The changes are in PlayerLocomotion's ladder states (NextBotPlayerLocomotion.{h,cpp}), the two ladder behaviors (neo_bot_ladder_approach.{h,cpp}, neo_bot_ladder_climb.{h,cpp}), one block in PathFollower::Climbing() (NextBotPathFollow.{h,cpp}) and a CNavLadder::GetTopArea() helper (nav_ladder.h) that the climb and the locomotion both use to find a ladder's top area, since nav_generate puts it in whichever top slot its landing is in, often not the forward one. Engine movement (CGameMovement) is not changed, and no convar switches any of it off: with a fix disabled a bot would hit the original defect. Climbing up a ladder the bot meant to climb, from the foot with nothing in the way, is unchanged.

Dependencies and status

Needs (code PRs this one depends on):

  • None.

Helped by:

  • None.

Pending before review:

  • A Windows build (built and tested on Linux so far).
  • The author's decision whether to keep the 15 changes in one PR or split them into stacked PRs.

Navmesh PRs that rely on this

Needs this PR: NeotokyoRebuild/neoAssets#140 (ntre_terminal_jgr), #143 (ntre_ridgeline_ctg), #149 (ntre_tarmac_ctg), #154 (ntre_yard_jgr). Measured to help: #146 (ntre_saitama_ctg), #148 (ntre_subsurface_ctg), #150 (ntre_dusk_ctg), #151 (ntre_dawn_ctg), #157 (ntre_apparatus_dm), and the meshes measured only on a build that carries this PR: #160 (ntre_rise_ctg), #161 (ntre_threadplate_ctg), #162 (ntre_sentinel_ctg), #163 (ntre_ghost_ctg), #164 (ntre_skyline_ctg), #165 (ntre_sentinel_jgr), #166 (ntre_vtol_ctg); per-mesh ladder results are under "Results".

What this PR does

In the order a bot meets them: walking past a ladder, the approach, the mount, the climb, the dismount. Numbers are master against this branch unless a line says otherwise; the tests are described under "Results".

Walking past a ladder

  1. Ride an unwanted ladder out (NextBotPlayerLocomotion.cpp). CGameMovement::LadderMove() puts a player on any ladder their wish direction points at, and PlayerLocomotion::TraverseLadder()'s NO_LADDER branch drops the move type back to walking whenever the bot did not ask for a climb. A path that only brushes past a ladder leaves the two toggling every tick: the bot hangs there, and since it never stops moving, no stuck event fires. Once the contact has lasted half a second, the locomotion now adopts the ladder and leaves it by the nearer end. Unwanted ladder time 11.0 -> 7.8 s a match (x0.71 [0.58, 0.86], 216 matches on 7 maps).

The approach

  1. Wait out the pre-round freeze (neo_bot_ladder_approach.cpp). A bot that spawns within reach of a ladder starts its approach during the freeze, which holds it still: a descent over a level top (change 5) reads that as being blocked behind the ladder, and an approach's 3 s timeout runs out before the bot can move. The approach now waits while the bot's own freeze flag is set (NEO_FL_FREEZETIME, as the bot locomotion and the breakable-clearing behavior test it). Ridgeline ladder 1: descents begun in the freeze that mount the ladder 0 of 125 -> 125 of 125; climbs up begun in the freeze that time out 60 of 145 -> 1 of 136 (this branch without and with the change). Only the approach waits because it is the one ladder step with a stall timer and a timeout; the climb starts only from the approach, so it cannot begin in the freeze.
  2. Mount a descent from in front of the ladder (neo_bot_ladder_approach.{h,cpp}). The engine grabs a bot as soon as its move points at the ladder, often while it still stands on the landing or on the brush's top, and from there no press takes it down. The approach now heads for where a descending bot hangs, half a hull plus 2 u in front of the face at the ladder's top, looks down the ladder, and mounts only once the bot is below the top. With changes 10 and 15, saitama's hatch descents ending back on the car roof went 95 % -> 5 %; across every ladder in the ladder tests, descents finished 43 of 107 -> 164 of 201 (normal play: 35 % -> 81 %).
  3. Mount over a barrier at the top (neo_bot_ladder_approach.{h,cpp}, NextBotPlayerLocomotion.{h,cpp}). Some ladders top out behind a roof parapet or a catwalk handrail (ntre_yard_jgr ladders 3, 4, 5, 8, 9, 10, 13, 14, 15). From behind the barrier the approach walked into it until its timeout; from on top the bot walked off forward and fell past the face. A descent that starts behind the ladder's plane with the top more than a step above its floor now hops onto the barrier and backs out over the drop, crouched. PlayerLocomotion::CatchLadderBelowTop() then presses forward and down on the tick the bot is in the air below the top: bot updates come ten times a second, too seldom for the engine's 2 u grab reach, so the press is made in Upkeep(), every tick, while a catch is armed. Yard's nine barrier ladders going down, three runs each: master finished 10 of 27 descents, this branch 117 of 124.
  4. Mount over a top level with the floor (neo_bot_ladder_approach.{h,cpp}, NextBotPlayerLocomotion.cpp). Ridgeline ladder 1's top is level with the catwalk, so a bot behind it stands on the brush's own top and walks off. Change 4's mount now covers it too; a bot held behind the face for 0.6 s goes back to the ordinary approach, and the catch presses from a step above the nav ladder's top, since the brush often stands above it (13 u there). Ridgeline, with the 140 u drop beside the ladder removed so that descents must use it: descents mounted over the top, 0 of 171 down approaches on master, 97 of 98 on this branch.
  5. Stand up below a ladder's hanging foot to mount it (neo_bot_ladder_approach.{h,cpp}). Some ladders end above open floor (ntre_sentinel_ctg ladders 3 and 6, whose foot hangs 54 u up). A bot that climbs down drops the rest of the way and lands crouched, since the bot locomotion presses crouch in the air. If it then heads back up, the approach walks it to the ladder's bottom, under the brush, where the crouched hull fits and cannot stand: it never touches the ladder, and the approach times out and starts again every 3 s, for up to 40 s. Master has the same stall; it shows more on this branch because more descents finish there. Going up a ladder whose bottom is more than a step above the bot's feet, the approach now releases the crouch and, while the bot is still crouched, walks it to just in front of the face, where it stands and mounts as usual. Sentinel ladders 3 and 6, 12 ladder runs a build: near-ladder OnStuck 123 a run on master, 79 on this branch without the change, 46 with it; stalls under the foot 14, 9 and 0. Normal play on sentinel, 18 matches a build: near-ladder stuck 16.6 -> 5.3 a match (this branch without and with the change; master 3.8 and 10.2 in two batches).

The mount

  1. Claim the ladder a climb mounts, and keep the claim (neo_bot_ladder_climb.cpp, NextBotPlayerLocomotion.cpp). CNEOBotLadderClimb put the bot on a ladder without telling PlayerLocomotion, so change 1 would treat every behavior climb as unwanted and take it over by the nearer end: at the foot, the bottom, reversing the climb. The climb now calls ClimbLadder() / DescendLadder() at the mount, claims the ladder again when the engine reports the bot off it for a tick mid-climb, and a claim restarts change 1's half-second wait. Behavior climbs finished from a teleported start 78 % -> 88 % (same 216 matches as change 1).
  2. Keep the climb's teleport out of the brush and out of solid (neo_bot_ladder_climb.cpp). When a climb starts with its way up blocked, OnStart() places the bot in front of the face. The hull is axis-aligned, so on a turned ladder a corner of it went into the brush; on a slope the kept feet height could be under the floor; and where the hull did not fit, the fallback spot half a hull off the nav ladder could be inside the brush (ntre_rise_ctg ladder 6, whose brush stands in front of its nav line). The placement now clears the hull's corners and slides in until it touches the face, stands the hull on the floor under the spot, and skips a spot still in solid, so the bot climbs from where it is. Ridgeline ladder 1, runs with a bot held at the foot for the rest of the round: 80 of 80 on master, 0 of 80. Rise ladder 6 up, runs that lost a bot inside the brush: 2 of 16 -> 0 of 24 (this branch without and with the solid check).
  3. Turn the view with the teleport (neo_bot_ladder_climb.cpp). The teleport set the body's angles but not the view (SnapEyeAngles), so the first forward press climbed off the face's normal and slid the bot off the side of a narrow ladder. Ridgeline ladder 1, teleported climbs that ended back at the foot: 32 of 102 -> 19 of 92 (this branch without and with the change, on an earlier test build).

The climb

  1. Press forward down a ladder only when it descends (NextBotPlayerLocomotion.{h,cpp}, neo_bot_ladder_climb.cpp). LadderMove() turns the part of a move that pushes into the face into climbing up: facing the ladder, forward goes down only while the view points more than about 45 degrees down. Descents pressed forward before the view was down and climbed back off the top. PlayerLocomotion::IsForwardDownLadder() says whether a forward press moves the bot down, from the view and the face it holds; Approach() and the climb press nothing on a descent until it does, and DescendLadder() looks down into the face. With changes 3 and 15, descents ending back on top on eight other ladder maps: 112 of 137 -> 0 of 32 (the measurement of parts 4-6 below).
  2. Shimmy past a snag before giving up a climb (neo_bot_ladder_climb.{h,cpp}). A climb that stops rising part-way is usually caught on something at the ladder's edge: on ntre_subsurface_ctg ladder 1 the landing plate overlaps the ladder by about 1 u, and on saitama ladder 2 bots catch the hatch rim. A stalled climb now shimmies sideways, right then left, three times of 0.4 s each (one progress-check interval), before it jumps off. Subsurface ladder 1 climbs up 34 -> 72 % finished, saitama ladder 2 rim snags 21 -> 0 (measured on an earlier test build carrying the same change). Climbs abandoned with "Got stuck on something climbing" across every ladder tested: 79 on master, 5. The first version of this change meant the shimmies to grow (0.3, 0.6, 0.9 s) but its progress check cut each one at 0.4 s, which sunmachine's review found; the numbers above were measured with the 0.4 s shimmies, and a 312-match comparison on this branch of the as-is code, the growing version and three equal 0.4 s shimmies found no difference in climbs finished, stuck events or falls (a second or third shimmy happens in under 5 % of stalls), so the code now says what was measured.
  3. Climb onto a ladder's wall-cap top by the mesh link (NextBotPathFollow.{h,cpp}). This is the one change outside the ladder code. To go down a ladder whose top area is a wall cap shallower than a hull, the bot first climbs onto the cap, and the path follower's ledge search looks for the ledge's floor a full look-ahead past the wall, past a cap that thin: the bot never jumped, and no bot descended ntre_rise_ctg's ladder 4. When the area after a CLIMB_UP is a ladder's top and shallower than a hull along the approach, Climbing() now takes the mesh's climb link at the launch point, as its authoritative-mesh branch already does, at most three times a path segment. The rule meets 8 walk links on the ntre_ meshes (rise ladders 4 and 5, dawn and dusk ladder 2, saitama ladder 11). Rise ladder 4 down, on rise's navmesh with its cap edit: no descent before, 14 of 14 reaching the foot after (measured on an earlier test build carrying the same change). With the three-launch bound, the ladders that take this link most, three runs each with the bound: dawn and dusk ladder 2 up finished 26 of 26 and 22 of 22 climbs (master 7 of 17 and 10 of 20, with 8 falls from the top each), saitama ladder 11 down 3 of 4 (master 0 of 1); rise ladder 4 down is not used by either build on Animations networking #160's current navmesh, which lacks the cap edit.

The dismount

  1. Take a climb's exit from after its ladder (neo_bot_ladder_climb.cpp). ResolveExitArea() took the first non-ladder area from the path follower's current goal. When that goal was still the area at the ladder's foot, a climb up resolved its exit below it and the dismount kicked the bot backwards off the top: ntre_dusk_ctg / ntre_dawn_ctg ladder 4 did this 71 times in 627 climbs, 4 in 604 with the change (part 8 below). The exit is now the area the ladder lands on, from the path after this ladder. Dawn and dusk ladder 4 up on this branch: 18 of 18 finished each.
  2. Dismount toward the landing's near point (neo_bot_ladder_climb.{h,cpp}). The dismount aimed at the exit area's center, which on a long, narrow landing lies off to one side: on ridgeline ladder 1 it is the middle of the catwalk, and the kick toward it carried bots off the 30 u slab. The dismount now aims half a hull inside the landing's point nearest the ladder and finishes once the bot stands on the landing. A landing under half a hull deep is a wall cap the climb crosses (dawn and dusk ladder 1, 7 u deep), so it keeps the center aim. Ridgeline ladder 1, 16 runs each, measured on an earlier test build carrying the same change: climbs up ending on the catwalk 58 of 111 -> 69 of 94, falls from the top 11 -> 0, near-ladder OnStuck 67 -> 12. Dawn and dusk ladder 1 up: master 0 of 1 and 0 of 1 started, this branch 12 of 20 and 9 of 15.
  3. Keep a descent's early jump-off below the top (neo_bot_ladder_climb.cpp). The climb jumps off once the exit is within a safe drop below, which on a short ladder is at the mount, and where the top floor overhangs the exit (saitama's car roof) that lands the bot back on top. Going down, the jump-off now waits until the bot is a standing height below the ladder's top. Part of saitama's 95 % -> 5 % (change 3).

How to test

Build this branch, install the map's navmesh from its neoAssets PR into maps/nav/, then map <map>, neo_bot_quota 12 and join spectator. sv_cheats 1; nb_debug behavior prints each ladder step a bot takes ("Approaching ladder down", "Mounting ladder over its top", "Reached the ground, ending climb", "Got stuck on something climbing"); nb_debug path also prints the climb's teleport. sv_neo_wep_dmg_modifier 0.1 keeps bots alive near a ladder longer, and nav_edit 1 draws the nav ladders. A build without this PR on the same navmesh shows master.

Risk

Moderate. Ladder handling changes on every map: every behavior climb now runs through PlayerLocomotion's ladder states, every descent mounts from the hang point, and changes 4 and 5 act only on descents that start behind the ladder's plane. Change 6 acts only on a bot going up a ladder whose foot is more than a step above its floor, and change 12 only where the area after a climb is a ladder's top shallower than a hull.

What was checked: every nav ladder on the 22 ntre_ maps with a draft navmesh, 168 of 169 (apparatus ladder 1 has no top area), both directions, one run each on master and this branch; the 98 ladder directions where either build showed a fault, three more runs each; and ridgeline ladder 1 in 80 runs a build. These ran on the build before the hanging-foot fix (change 6); sentinel_ctg ladders 3 and 6 (12 runs a build) and ridgeline ladder 1 (64 runs a build) were run again with it. Every run reached match end with no crash. A Debug build of this branch before change 6 ran one 30-bot match each on ridgeline, saitama, tarmac, rise, yard and terminal plus 16 of the ridgeline ladder runs (22 matches): all reached match end, with zero assertion failures and no crash. The same 22 Debug matches ran again on the branch with change 6: all reached match end, zero assertion failures, no crash. Pass marks, this branch against master: no run with a bot held still on a ladder; no repeated bounce loop (a bot mounting one ladder 5 times without finishing) where master has none; no fall death within 3 s of leaving a ladder; no ladder direction master finishes that this branch never finishes. Two directions missed the last mark on the build before change 6; with it only the first still does:

  • saitama ladder 13 down. Master finished 9 of 22 descents, this branch 0 of 2. The six bots of a run start on the hatch top beside ladder 4's top; on this branch they walk to the mount point, find a teammate standing in it, and time out (23-31 approach timeouts a run, master 6-16). It is crowding at the hatch top: going up, this branch finishes 27 of 27 ladder 13 climbs (master 15 of 53), and the bots then pile on the ghost there. In normal play (34 matches a build) no bot mounted ladder 13 on either build.
  • ntre_sentinel_jgr ladder 7 down. Master used it in 2 of 3 runs (3 of 5 finished); this branch's bots never choose it (no approach, no timeout). Route choice, not a failed climb.

One spot was worse on this branch in normal play ("Normal play" under Results), and change 6 fixes it:

  • ntre_sentinel_ctg. Near-ladder stuck rose 69 -> 298 in 18 matches a build (3.8 -> 16.6 a match), with no deaths and no trapped bot. It was the stall at ladders 3 and 6's hanging feet (change 6); rerun with the change, this branch has 5.3 a match, master 3.8 and 10.2 in two batches.

Known, not caused by this PR:

  • The top of rise ladder 3, a 16 x 46 u dead-end cap at (-219, -952): a bot that stalls there is snagged too hard for any shimmy to free it, and every stall ends in the jump-off (17 of 18 stalls on this branch in the shimmy comparison, all three shimmy variants alike).
  • Ladders both builds fail: rise ladders 5 and 6 up (this branch 27 % and 22 % of climbs finished, master 8 % and 9 %), ntre_transit_ctg ladder 2 down (13 % / 25 %), ntre_apparatus_dm ladder 13 (climbs under a slab). Rise ladder 3 up, yard ladder 11 down and rise ladder 4 down are used by neither build; rise ladder 4 down needs Animations networking #160's cap edit (change 12), which that PR's current navmesh lacks.
  • A hop beside ridgeline ladder 1's foot, near (1028, 3020): a bot that has fallen off the ladder can hop in place there for up to 20 s while its approach to the foot times out, 3 of 80 runs on this branch. Master's runs show the spot too.
  • Dawn and dusk ladder 1 up: this branch climbs the 7 u wall cap, master never tries; the misses are bots that reach the cap and drop back off it.
  • Late let-go near the foot: on ridgeline ladder 1, about 1 in 6 descents by the ladder (20 of 120 in the 32 down runs below) let go 70-110 u above the foot and drop the rest of the way. In a 32-run check of these falls none cost health (master: 2 of 34 descents fell, both with damage). This branch has about three times the descents there (120 against 43), since fewer bots take the 140 u drop beside the ladder.
  • Apparatus's pits under the catwalks: bots dying in a trigger_hurt after walking off a catwalk they climbed to, master 19 / this branch 13 in the all-ladder runs and 5 / 15 in the reruns, with more finished climbs on this branch in both.
  • When the hull fits nowhere in front of the face, the climb starts from where the bot stands; if it then stalls, it shimmies, jumps off, and its next path may lead it back to the same ladder. That loop is bounded by path choice, not by a count.
  • ntre_sentinel_ctg ladders 3 and 6: a bot that lands under the ladder's brush crouched while following a path or fighting, not approaching the ladder, can stay wedged there (1 bot in 18 matches on this branch, 5 on master).
  • ntre_sentinel_ctg ladder 3: a bot standing at the face in its approach and not grabbed by the ladder for 30-42 s (1 bot on each build).
  • Ladders on turned brushes: the code reads the nav ladder's run-time normal, which CNavLadder::SetDir() takes from the brush face, so a turned ladder is climbed the same way as a square one.

Cost

Per bot. Worst case on a server tick: a virtual Upkeep() call and a null check for every bot; while a catch is armed (up to 0.5 s after each over-the-top approach update) a dynamic_cast, an EyeVectors, a GetPosAtHeight and five dot products, no trace. Worst case on a bot update (10 a second): one hull trace while a bot stands behind a barrier lined up for change 4, plus two IsForwardDownLadder() calls on a descent (an EyeVectors and two dot products each), plus four float compares in the approach (and, while a bot going up a hanging-foot ladder is still crouched, one point in front of the face). Per event:

where work when
HandleUnwantedLadder() a timer; at adoption, a loop over the nav ladders (48 on terminal) with a GetPosAtHeight each in the height window every locomotion update on an unwanted ladder; the loop once the contact passes half a second
ClimbLadder() / DescendLadder() two stores once per claim
CNEOBotLadderClimb::OnStart() three hull traces beside the existing one once per mount whose way up is blocked
ResolveExitArea() a walk of up to 100 path segments, a full path walk, two closest-point queries twice per climb
climb Update() a ladder-state check; a Min of two sizes each climb tick; each dismount tick
PathFollower::Climbing() NextSegment, a ladder count, the area's center and size, a counter; at launch a closest point and the existing ClimbUpToLedge each path update on the ground with a CLIMB_UP goal; launch at most three times a segment

All of it is small beside the stock Climbing() ledge search, which runs several hull traces per update.

Results

How the ladder tests run. A run is one 2-minute round with 12 bots (6 a team, 2 of each class): one team spawns at one end of a ladder and the ghost lies at the other, so the team has to use the ladder; weapon damage is a tenth and the pre-round freeze 2 s. Unless a line says otherwise, this branch's numbers are from the build before the hanging-foot fix (change 6), which acts only on a bot going up a ladder whose foot is more than a step above its floor. Both builds are Release builds with the same test-only logging (positions twice a second, stuck events, behavior changes); master is upstream at #2205, which touches none of this PR's files. Each map runs its navmesh at its neoAssets PR's head. A climb is "finished" when the bot leaves the ladder at the far end and stands there for a second; "near-ladder OnStuck" counts OnStuck events within 300 u of the ladder in the round.

ntre_ridgeline_ctg ladder 1, #143's navmesh. Climbs up: the team at the foot, the ghost on the catwalk; down: the reverse. The copy without the drop is a test-only copy of the navmesh with the 140 u drop link beside the ladder cut, so descents must use the ladder.

master this branch
runs with a bot held at the ladder for 10 s or more (32 up, 32 down, 16 down on the copy without the drop) 80 of 80 0 of 80
climbs up finished on the catwalk, 32 up runs 18 of 135 167 of 173
climbs up that ended back at the ladder's foot, 32 up runs 14 of 135 (10 %) 1 of 173
falls from the top after a climb up, 32 up runs 1 1
descent approaches that mounted over the level top, copy without the drop 0 of 171 97 of 98
first descents from the catwalk by the ladder rather than the drop, 32 down runs 35 of 247 (14 %) 113 of 190 (59 %)
descents finished at the foot, 32 down runs 23 of 43 99 of 120
fall deaths within 3 s of leaving the ladder 5 0
near-ladder OnStuck in a run median 445-476, max 634 max 19, none over 30

Rerun with change 6 (32 up and 32 down runs), this branch held a bot in 0 of 64 runs, finished 174 of 182 climbs up and 95 of 122 descents, with 0 fall deaths and at most 5 near-ladder OnStuck in a run. Master's column was measured in an earlier batch of the same scenarios. With the drop link kept, about 4 in 10 bots still take the drop, which costs no health; on the copy without it, 95 of 96 first descents used the ladder.

Every ladder on the ntre_ maps (ladder runs, not normal play). All 336 directions of 168 ladders, one run each, pooled:

master this branch
climbs up finished 380 of 713 (53 %) 659 of 747 (88 %)
descents finished 43 of 107 (40 %) 164 of 201 (82 %)
directions with a finished climb 136 195
near-ladder OnStuck 7019 3545

These ran before changes 2 and 8 took their final form. The 98 directions where either build showed a fault (a hold, a bounce loop, a failed direction, high OnStuck), plus the ladders this PR names, were then run three more times each on this branch before its last review edits (which change no behavior at these ladders; the ladders change 12's bound touches were rerun after it, above), master's three runs being the same build. On that set master finishes 410 of 959 climbs (43 %), this branch 921 of 1136 (81 %); of this branch's 349 descent mounts, 63 are a bot brushing the ladder's top lip or foot while walking, and of the 286 real descents 240 finish (84 %, master 32 of 129).

The 98 watch directions ran three more times on the branch with change 6: climbs up finished 83.2 % of starts, descents 77.3 % (85.6 % of the descents that reached the ladder), 81.4 % overall, with a finish in 83 directions against master's 69 and this branch's 81 before the fix. No run with a bot held still on a ladder, no bounce loop where master has none, no fall death within 3 s of a ladder. Rise ladder 6 up had no held bot in 66 climbs. saitama ladder 13 down still misses (crowding at the hatch top); ntre_sentinel_jgr ladder 7 down no longer does. Sentinel ladders 3 and 6 up finished 25 of 27 and 37 of 38 (before the fix 18 of 19 and 25 of 26).

map, neoAssets PR ladders directions finished / started, master finished / started, this branch directions with a finish, master / branch near-ladder OnStuck, master / branch rerun directions rerun finished / started, master rerun finished / started, this branch rerun bounce-loop runs, master / branch rerun near-ladder OnStuck, master / branch rerun held runs / fall deaths, branch
apparatus_dm #157 32 64 73 / 99 128 / 140 27 / 35 656 / 29 12 96 / 131 151 / 164 0 / 1 1688 / 40 0 / 0
bullet_tdm #155 4 8 2 / 4 9 / 12 2 / 3 21 / 7 -
dawn_ctg #151 4 8 9 / 19 20 / 25 2 / 4 5 / 11 6 31 / 47 65 / 75 0 / 0 5 / 9 0 / 0
disengage_ctg #153 2 4 10 / 17 12 / 12 2 / 2 0 / 0 1 3 / 4 5 / 5 0 / 0 0 / 0 0 / 0
dusk_ctg #150 4 8 4 / 18 25 / 36 2 / 5 6 / 2 7 14 / 34 67 / 76 0 / 0 15 / 6 0 / 0
engage_ctg #152 2 4 10 / 14 14 / 14 3 / 2 10 / 69 2 19 / 26 21 / 21 0 / 0 101 / 140 0 / 0
ghost_ctg #163 2 4 3 / 13 14 / 17 1 / 3 443 / 19 -
marketa_ctg #147 1 2 6 / 9 7 / 8 1 / 1 11 / 0 -
ridgeline_ctg #143 1 2 1 / 13 6 / 7 1 / 1 796 / 0 2 1 / 21 19 / 20 0 / 0 2677 / 7 0 / 0
rise_ctg #160 6 12 18 / 53 33 / 54 3 / 6 2489 / 1408 7 9 / 104 21 / 93 3 / 4 6571 / 3975 0 / 0
rogue_ctg #158 5 10 24 / 48 38 / 44 6 / 5 31 / 33 3 16 / 27 30 / 33 0 / 0 50 / 106 0 / 0
saitama_ctg #146 7 14 48 / 88 81 / 102 10 / 12 390 / 621 7 79 / 171 151 / 191 2 / 0 541 / 838 0 / 0
sentinel_ctg #162 8 16 36 / 74 95 / 110 10 / 12 311 / 306 10 73 / 155 153 / 191 1 / 0 422 / 279 0 / 0
sentinel_jgr #165 8 16 8 / 13 9 / 10 6 / 6 29 / 13 9 11 / 31 19 / 22 0 / 0 45 / 51 0 / 0
skyline_ctg #164 3 6 19 / 29 18 / 19 4 / 3 68 / 20 1 1 / 2 7 / 7 0 / 0 36 / 6 0 / 0
subsurface_ctg #148 9 18 36 / 56 64 / 74 11 / 15 472 / 34 2 10 / 23 22 / 23 0 / 0 20 / 0 0 / 0
tarmac_ctg #149 2 4 12 / 16 14 / 17 3 / 2 41 / 192 3 20 / 29 33 / 42 0 / 0 85 / 45 0 / 0
terminal_jgr #140 48 96 39 / 136 113 / 113 26 / 55 62 / 17 6 7 / 107 26 / 26 2 / 0 0 / 0 0 / 0
threadplate_ctg #161 6 12 36 / 54 48 / 50 6 / 7 683 / 689 -
transit_ctg #145 2 4 13 / 25 14 / 17 3 / 2 24 / 31 1 2 / 8 1 / 8 0 / 0 38 / 0 0 / 0
vtol_ctg #166 1 2 5 / 7 9 / 9 2 / 2 0 / 0 -
yard_jgr #154 11 22 11 / 15 52 / 58 5 / 12 471 / 44 19 18 / 39 130 / 139 0 / 0 1184 / 20 0 / 0
all 168 336 423 / 820 823 / 948 136 / 195 7019 / 3545 98 410 / 959 921 / 1136 8 / 5 13478 / 5522 0 / 0
  • Rise's, subsurface's and threadplate's near-ladder OnStuck includes one direction each (rise ladder 3, subsurface ladder 10, threadplate ladder 3, down) where all six bots of the team stayed at the spawn in both builds, about 630 events a run on rise; those directions say nothing about the ladder.
  • Tarmac's 192 is one run in which four bots fought at the top of ladder 1; the three reruns have 2, 12 and 3 (master 0).
  • Saitama's and engage's higher OnStuck on this branch in the ladder runs is bots standing at a ladder's top after more climbs reach it (saitama: the hatch top, under "Risk").
  • Master holds the whole team at the foot of ghost ladder 2 and apparatus ladder 22 up, as at ridgeline (all 6 bots within 18 u of the foot for 45-117 s); this branch, none.

Normal play. Bot matches with 30 bots at default weapon damage, every pinned ghost or juggernaut spawn of each ladder map twice, one round, 8 matches at a time; master and this branch with the same spawns and the same machine load. These are ordinary matches, not ladder runs: the bots go where the game sends them. The table and the pooled line below predate change 6; sentinel_ctg was rerun with it (below). Stuck and kills are per match, stuck counted within 300 u of a ladder; fall deaths are totals (a death by the world within 300 u of a ladder). The last column is ghost captures of the CTG rounds; for the juggernaut maps it is the rounds ending at the score limit and at the time limit, and for the DM and TDM maps the matches ending at the score limit.

map matches, master / branch near-ladder stuck, master / branch fall deaths near ladders, master / branch kills, master / branch captures or juggernaut outcome, master / branch
apparatus_dm 2 / 2 22.0 / 17.5 0 / 0 258.0 / 261.5 score limit 2 of 2 / 2 of 2
bullet_tdm 2 / 2 9.5 / 7.0 0 / 1 180.5 / 181.5 score limit 2 of 2 / 2 of 2
dawn_ctg 10 / 10 1.6 / 0.5 0 / 0 25.8 / 25.7 0 / 0 captures of 10
disengage_ctg 16 / 16 0.0 / 0.1 0 / 0 24.1 / 24.6 3 / 0 captures of 16
dusk_ctg 10 / 10 3.1 / 0.6 0 / 0 25.0 / 25.3 2 / 2 captures of 10
engage_ctg 16 / 16 0.0 / 0.0 0 / 0 25.4 / 24.4 1 / 2 captures of 16
ghost_ctg 20 / 20 6.5 / 1.6 0 / 0 24.4 / 24.4 0 / 3 captures of 20
marketa_ctg 40 / 40 0.3 / 0.2 0 / 0 24.9 / 25.5 1 / 6 captures of 40
ridgeline_ctg 12 / 12 57.0 / 0.0 0 / 0 24.7 / 25.5 4 / 1 captures of 12
rise_ctg 8 / 8 6.2 / 1.4 0 / 0 27.0 / 26.6 0 / 0 captures of 8
rogue_ctg 10 / 10 0.6 / 0.5 1 / 1 24.4 / 25.7 1 / 1 captures of 10
saitama_ctg 34 / 34 7.0 / 3.1 0 / 0 24.4 / 25.6 8 / 4 captures of 34
sentinel_ctg 18 / 18 3.8 / 16.6 0 / 0 23.4 / 24.0 2 / 1 captures of 18
sentinel_jgr 18 / 18 17.2 / 10.7 0 / 0 114.3 / 100.6 score limit 4, timeout 14 / score limit 7, timeout 11
skyline_ctg 20 / 20 7.5 / 7.0 0 / 0 25.6 / 25.4 1 / 0 captures of 20
subsurface_ctg 16 / 16 1.2 / 2.1 0 / 0 25.4 / 25.2 1 / 2 captures of 16
tarmac_ctg 20 / 20 4.6 / 0.5 0 / 0 25.7 / 25.3 1 / 4 captures of 20
terminal_jgr 10 / 10 9.2 / 6.1 0 / 0 60.4 / 73.7 score limit 9, timeout 1 / score limit 10, timeout 0
threadplate_ctg 18 / 18 0.4 / 0.6 0 / 0 25.2 / 25.5 0 / 0 captures of 18
transit_ctg 10 / 10 1.0 / 2.3 0 / 0 25.5 / 24.1 0 / 0 captures of 10
vtol_ctg 28 / 28 0.1 / 0.0 0 / 0 24.4 / 24.9 0 / 1 captures of 28
yard_jgr 6 / 6 14.0 / 8.0 0 / 0 98.8 / 106.2 score limit 3, timeout 3 / score limit 1, timeout 5

All 22 maps, 344 matches a build: stuck 4666 / 3705 (13.6 / 10.8 a match), near-ladder stuck 2069 / 1041 (6.0 / 3.0 a match), kills 11742 / 11767, CTG captures 25 / 27 of 306 rounds, fall deaths within 3 s of leaving a ladder 0 / 0 (the 1 and 2 world deaths near a ladder, on rogue and bullet, were bots walking on flat floor). Climbs up finished 1715 of 3007 (57.0 %) / 2329 of 2823 (82.5 %), descents 211 of 598 (35.3 %) / 756 of 937 (80.7 %). Runs with a bot standing still within 64 u of a ladder for 10 s: 17 / 6, 6 of master's 17 on a ladder, none of this branch's; bounce loops 39 / 2.

At the places the ladder runs singled out: ridgeline ladder 1's foot went 684 -> 0 near-ladder stuck events in 12 matches (master wedged bots in 3 of the 12), with no new spot within 300 u on this branch. Terminal ladder 8: mounts that returned to the foot 425 -> 0 over 10 rounds, bounce loops 32 -> 0, and 194 of this branch's 221 mounts finished. Tarmac ladder 1: near-ladder stuck 92 -> 10. Yard's barrier ladders going down: 1 mount on master, 17 on this branch, 15 of them reaching the bottom. Saitama ladder 13 down, the direction that missed in the ladder runs, did not come up: no bot mounted it in 34 matches on either build, so the crowding at the hatch top did not show. The one large rise is ntre_sentinel_ctg (the next largest are subsurface 19 -> 34 and transit 10 -> 23 events): near-ladder stuck 69 -> 298 (3.8 -> 16.6 a match), with no deaths and no trapped bot, and 3-4 times more descents finished on its ladders 3 and 6. Bots that came down there and then headed back up stalled under the ladders' hanging feet (change 6). Rerun with change 6, 18 matches a build: near-ladder stuck 10.2 a match on master, 5.3 on this branch, 1 event within 300 u of ladder 3 (master 114). Master's own rate moves 3.8 -> 10.2 between the two batches, so at this size it is noisy.

Approaches tried and dropped

  • Skip a ladder after failed climbs (and skip an unreachable one): it acted 3 times in 96 matches, too seldom to carry its code.
  • Lift a player who grabs a ladder (CGameMovement): cleared ntre_skyline_ctg ladder 1's approach timeouts (2.4 -> 0), but it changes movement for every player, humans included; left for its own PR.
  • Hold MoveUp / MoveDown on a ladder: the presses do not reach the user command (a separate upstream button-duration defect) and risk popping bots off the ladder; left for its own PR.
  • One wall-cap threshold for changes 12 and 14: the two tests ask different things (whether the ledge search can find the cap, whether a bot can stand on the landing), each is measured as it is, and unifying them would need its own measurement.
  • Drop the teleport's fallback spot and skip the teleport whenever the hull does not fit clear of the face: the solid check of change 8 already skips the fallbacks that land in a brush (23 skipped on rise ladder 6, no bot lost in 24 runs), so the extra branch bought nothing measured.
  • Adopt an unwanted ladder only upward, or on contact without the half-second wait: both did worse than change 1 (upward sends bots where they must come back from, and cost captures).
Measurements of the earlier parts against the upstream of their time

These were measured while the PR was built up, each part against the code before it, on upstream as it was then (some with the SeekWeapon PR #2186, merged since), in 30-bot matches at default weapon damage, 8 at a time, with a test-only switch per part. Parts 1-3 here are changes 1 and 7 above; parts 4-6 are changes 3, 10 and 15; part 7 is change 4, part 8 change 13, part 9 change 5, part 10 changes 8 and 9. "Stuck" is OnStuck plus FAIL_STUCK move failures without the round-start spawn crowd; intervals are 95 % bootstrap intervals of the paired ratio.

Parts 1-3, on the navmeshes then in progress; subsurface, sentinel CTG, sentinel JGR, saitama, rise (6 pinned spawns x 3), bullet TDM and apparatus DM (12 each). 216 matches, 108 a build. Ladder time is sampled every 0.5 s and split into a behavior climb, a locomotion climb, or neither (an unwanted ladder).

per match upstream parts 1-3 paired
unwanted ladder time 11.0 s 7.8 s x0.71 [0.58, 0.86]
unwanted bouts 16.2 14.7 x0.91 [0.79, 1.04]
longest bout in the batch 6.5 s 4.5 s
behavior climbs finished, teleported starts 78 % 88 %
behavior climbs finished, walked-on starts 81 % 83 %
stuck 49.8 50.7 x1.02 [0.87, 1.20]
fall deaths 3.6 3.5 x0.97
kills 101.4 103.3
ghost captures 0.17 0.23 n.s.

The stuck total is flat; each map's rise is one bot in one or two matches repeating a stuck event 40+ times at one spot (capped at 20 events a bot: x1.02 [0.89, 1.18]). Part 1 alone on all 29 maps, 213 matches a build: unwanted ladder time 10.2 -> 6.8 s.

Parts 4-6 against parts 1-3, 384 matches, each paired with the other build on the same map, spawn and repeat. A descent "ends back on top" when the bot is within 24 u of the ladder's top when the climb ends.

ntre_saitama_ctg, 68 pairs (every ghost spawn twice, on master's navmesh and on #146's):

a match parts 1-3 parts 1-6 paired
hatch ladder descents 25.1 2.9
... ending back on the roof 95 % (1630 of 1706) 5 % (10 of 200)
stuck 41.2 30.2 x0.71 [0.60, 0.87]
stuck in the ladder tower 19.2 6.5 x0.35 [0.24, 0.53]
stuck elsewhere 22.0 23.7 x1.11 [0.93, 1.31]
kills 49.3 49.3 x1.00 [0.98, 1.02]
fall and hurt-volume deaths 0 0

Parts 1-3 alone more than doubled the tower's stuck events (x0.41 [0.23, 0.75] with their behavior switched off, 34 pairs): a bot left on the roof was adopted by a tower ladder and climbed on into its walls. That is why both halves are one PR.

Other ladder maps, 72 pairs (transit, tarmac, subsurface, marketa, rogue, ghost CTG, yard JGR, bullet TDM): descents ending back on top 82 % (112 of 137) -> 0 % (0 of 32); stuck x0.92 [0.72, 1.16]; kills x0.99 [0.94, 1.03]; fall and hurt-volume deaths 9 -> 10 (rogue's canal, bullet's track). Transit ladder 2's descent began to work (94 % back on top -> 0 %).

Parts 7-8 against parts 1-6, 420 matches.

ntre_yard_jgr, the juggernaut pinned at each of its 3 spawns, 10 matches each, on a navmesh that keeps the barrier ladders climb-only and on #154's, which links them going down (120 matches): with parts 1-8, 77 of 84 finished descents reached the bottom (7 dropped off, 0 timed out); with parts 1-6 on the linked navmesh, 1 of 184 (109 timed out against the parapet or rail). Stuck x1.01 [0.79, 1.26] against the climb-only navmesh; kills a minute x1.00 [0.96, 1.05]; fall deaths 0.

ntre_dusk_ctg and ntre_dawn_ctg, 5 ghost spawns x 3 each, 60 matches: ladder 4 climbs kicked backwards off the top 71 of 627 -> 4 of 604; stuck at ladder 4's top 94 -> 30; stuck x0.87 [0.67, 1.11]; kills x0.99 [0.96, 1.02].

Other ladder maps, 44 pairs: stuck x0.96 [0.71, 1.31], kills x1.08 [0.97, 1.20], fall deaths 0 / 0.

Parts 9-10 on ridgeline with #143's navmesh, 12-bot ladder runs: bot held in the brush at the foot 16 of 16 on upstream, 7 of 32 with part 9, 0 of 32 with part 10. Part 9 with bots spawned on top, 8 runs a build: descents mounted from the top 0 -> 11, descent approach timeouts 15 -> 0. Part 10 on rise ladder 4 down, 12 runs a build: every run reached the goal in both, near-ladder stuck within the run-to-run spread (median 741 against 576, Mann-Whitney p = 0.26).

Toolchain

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

Linked Issues

@sunzenshen sunzenshen changed the title Stop bots fighting the engine over unwanted ladders Fix bots bouncing on ladders and failing to descend Oct 1, 2026
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 1, 2026
CGameMovement::LadderMove() turns the part of a move that pushes into
a ladder's face into climbing up. Going down, forward therefore only
descends once the view points further down than into the face; with
the view still level it climbs the bot up and, near the top, off the
ladder. With the climb claimed (NeotokyoRebuild#2176), PlayerLocomotion's descend
states steer every claimed descent, and they pressed forward at once:
ApproachDescendingLadder() kept steering towards the mount point after
the bot was already on the ladder, and Approach() presses forward on
any ladder it is descending.

- IsForwardDownLadder(): would a forward press move the bot down. A
  press facing away from the face is refused too: standing on a floor,
  LadderMove() pushes the bot off the ladder for it.
- Approach() presses nothing on a descent until that holds.
- DescendLadder() looks down into the face (the face of the ladder
  brush the bot holds, its back when grabbed from behind), not away.
- ApproachDescendingLadder() hands a bot already on the ladder straight
  to DescendLadder(), before its "within a jump of the bottom" check
  lets go of it at the top of a short ladder.
- CNEOBotLadderClimb holds forward the same way, does not count the
  wait for the view as a stall, and leaves the aim of a claimed descent
  to the locomotion: two MANDATORY aims at different points hold each
  other back, since PlayerBody rejects a same-priority aim until the
  head has been steady for 0.3 s.
@sunzenshen
sunzenshen force-pushed the bot-ladder-unwanted-release-0927 branch 2 times, most recently from c6c506e to 94200b6 Compare October 1, 2026 06:21
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 1, 2026
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 1, 2026
From tmp-phase2-measure-sq 8fbeda4 (never pushed, never part of a PR).
The research branch already carries the forensic POS/KILL/ROUND/STUCK/
LEAVEGROUND lines, the round/match-end markers and
sv_neo_ghost_spawn_pos, so only these come over: NEO_FORENSIC_LADDER per
TraverseLadder transition, the climb's "end" line, and
neo_harness_ladder_pr_off (1 = release, claim and the on-ladder ascend
check off, i.e. upstream behavior).
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 1, 2026
Never pushed, never part of a PR. Before this, neo_harness_ladder_pr_off
1 turned off only the release half of the squashed NeotokyoRebuild#2176 (94200b6),
so the "off" arm still ran the descent half (ex-NeotokyoRebuild#2187): a hybrid. Now:

  pr_off 1           whole squash off = c930bb4's ladder code
  descent_off 1      descent half off, release half on (= 9f0805f)
  both 0             94200b6

Each gate's off path, per hunk of git diff c930bb4 94200b6
(Off = NEOHarnessLadderDescentOff() = pr_off || descent_off):

NextBotPlayerLocomotion.cpp
  @-17  constants             no runtime effect, not gated
  @-45  Reset() init          state read only by HandleUnwantedLadder
  @-52  new helper bodies     reached only via the gated call sites
  @-88  HandleUnwantedLadder  pr_off: false && -> SetMoveType(WALK)
  @-115 ascend on-ladder ret  pr_off: skipped -> upstream too-far check
  @-149 descend on-ladder ret Off: skipped -> upstream mount approach
  @-281 DescendLadder aim     Off: goal reassigned to the #else line
  @-489 Approach() hold       Off: if false -> upstream if/else chain
NextBotPlayerLocomotion.h  2 hunks, declarations only
neo_bot_ladder_approach.cpp
  @-86  hang point            Off: offset skipped, targetPos = m_top
  @-99  look down             Off: skipped, eye-level center look
  @-107 mount gate +
  @-132 (same block)          Off: upstream if/else copied verbatim,
                              then return Continue() as upstream
neo_bot_ladder_approach.h  HANG_CLEARANCE, constant only
neo_bot_ladder_climb.cpp
  @-103 ClaimLadder at start  pr_off: ClaimLadder returns at once
  @-218 re-claim              pr_off: same no-op
        hold for view         Off: bHoldForView false, no timer reset
  @-251 early jump-off        Off: bBelowTopFloor true -> upstream if
  @-329 aim / forward         Off: aim always; bHoldForView false ->
                              PressForwardButton(0.1f) as upstream
neo_bot_ladder_climb.h     ClaimLadder declaration only

On path (both 0): every gate is "!Off() && X", "X || Off()" or an
"if ( Off() )" block, so it reduces to 94200b6's expression; the
added calls read cvars only. The off mount block in the approach was
diffed against c930bb4 after removing one tab: identical. Not built
(game servers running); the orchestrator builds and smoke-tests.
Some ladders top out on a barrier the bot reaches from behind the
ladder's plane: a roof parapet level with the ladder's top, a catwalk
handrail just above it. The descent approach heads for the hang point
in front of the face, so from the floor behind the barrier it walks
into the barrier until its timeout; from on top it walks off forward,
falls past the face moving away from it, and LadderMove() never sees
a move into the ladder.

A descent that starts behind the ladder's plane with the top more than
a step over its floor now goes over the top. The bot faces the face,
just below level, lines up behind the ladder, hops onto the barrier
from a standstill and backs out over the drop, crouched so it leaves
the top slowly. PlayerLocomotion::CatchLadderBelowTop() then presses
forward and down on the tick the bot drops past the ladder's top: bot
updates come ten times a second, too seldom for the engine's 2 u
reach, so the press is made in Upkeep(). Other descents are unchanged.
CNEOBotLadderClimb::ResolveExitArea() took the first area from the
path follower's current goal that is not a ladder. When that goal is
still the area at the ladder's foot, a climb up resolves its exit
below it, and the dismount's jump-off kicks the bot backwards off the
top. The exit is now the first area after this ladder in the path.
sunzenshen added a commit to sunzenshen/neoAssets that referenced this pull request Oct 3, 2026
Ladders 3, 8, 10, 13, 14 and 15 were climb only: their tops stand on
the roofs' 38 u parapets (10, 13, 14, 15) or behind the catwalk's
handrail (3, 8), and bots could not get onto them going down.
NeotokyoRebuild/neo#2176 now mounts such descents over the top edge,
so each ladder's top area lists it going down again.

Ladder 14's and 15's tops move from the 11 u walkway strips against
the north and south parapets, where no player hull fits, to the roof
floor in front of each ladder. The strips are removed, and the
parapet-top exits beside those ladders, reached only from the strips,
are linked from the ramp next to them (a 43.5 u climb with a drop
back). Re-analyzed in game.
sunzenshen added a commit to sunzenshen/neoAssets that referenced this pull request Oct 3, 2026
Ladders 3, 8, 10, 13, 14 and 15 were climb only: their tops stand on
the roofs' 38 u parapets (10, 13, 14, 15) or behind the catwalk's
handrail (3, 8), and bots could not get onto them going down.
NeotokyoRebuild/neo#2176 now mounts such descents over the top edge,
so each ladder's top area lists it going down again.

Ladder 14's and 15's tops move from the 11 u walkway strips against
the north and south parapets, where no player hull fits, to the roof
floor in front of each ladder. The strips are removed, and the
parapet-top exits beside those ladders, reached only from the strips,
are linked from the ramp next to them (a 43.5 u climb with a drop
back). Re-analyzed in game.
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 3, 2026
NeotokyoRebuild#2176's commits 4a54c61 and 57d8040 (yard's parapet / handrail
ladders, dusk and dawn ladder 4), ported onto the research branch.
A ladder that rises over a wall cap has its top area on the cap,
shallower than a hull. The path follower's ledge search looks for the
ledge's floor a full look-ahead past the wall, past a cap this thin,
so a bot at the launch point of the climb onto it never jumped, and
no bot descended that ladder (ntre_rise_ctg ladder 4). When the area
after a climb is a ladder's top and is shallower than a hull along
the approach, the follower now takes the mesh's climb link at the
launch point, as it already does on an authoritative mesh.

The rule meets 8 walk links across the ntre_ meshes: rise L4 and L5,
dawn and dusk L2, saitama L11. Research measurement: rise L4 down, 6
runs on the edited mesh, 14 descents mounted and 14 reached the foot
(0 before); dawn and dusk L2 and saitama L11, up and down, 3 runs
each: every climb reached the ghost.
A climb that stops rising part-way is usually snagged on something at
the ladder's edge rather than blocked outright. On ntre_subsurface_ctg
ladder 1 the landing plate overlaps the ladder column by about 1 u, so
a bot centered on the nav ladder heads into its underside at the same
height every time, while one 2 u off center climbs past (169 of 198
mid-climb stall endings were there). On ntre_saitama_ctg ladder 2 bots
catch the rim of the freight elevator hatch the same way.

Before jumping off, a stalled climb now shimmies sideways, right then
left, 0.3 s longer each time, up to three times, and gives up only if
none of that restores the climb.

Research measurement: subsurface ladder 1 up-climbs 34 -> 72 %;
saitama ladder 2, 12 v 12 runs, rim snags 21 -> 0, start-solid traps
in the slot below 207 -> 2, up-climbs 163 -> 177. Every-map screen,
4 ghost pins, 106 matches an arm: stuck events 6.22 -> 5.69 a match,
falls off a ladder 1.25 -> 1.01, no new stuck spot.
A bot that spawns within reach of a ladder starts its approach during
the pre-round freeze, and the freeze holds it in place. A descent over
a level top reads that as being blocked behind the ladder's face, drops
back to the ordinary approach and walks off the top past the face; the
freeze also runs out an approach's timeout before the bot can move.
The approach now waits while the freeze lasts.

Measured on ntre_ridgeline_ctg ladder 1 (descents from the catwalk,
12 bots, 2 s freeze, 8 wide): down approaches started in the freeze
mounted over the top 0 of 125 times before, 125 of 125 after (those
started after the freeze: 120 of 126, then 121 of 127). On the
no-drop twin of the map, over-top mounts 49 of 108 -> 98 of 99. Up
approaches timing out in the freeze 60 of 145 -> 1 of 136. No fall
deaths either way.
When a climb's way up is blocked, the climb teleports the bot in front
of the ladder's face. Two spots it could pick hold the bot there for
the rest of the round, still on the ladder with no velocity:

- When the hull does not fit even clear of the face, the fallback
  spot half a hull in front of the nav ladder can be inside the
  ladder brush itself (ntre_rise_ctg ladder 6, whose brush stands in
  front of its nav line).
- The spot keeps the old feet height, and on a slope the floor there
  can be a few units higher. A hull sunk into a displacement does not
  start solid, and a ducked bot unducking on the ladder then drops its
  feet by the crouch height into the floor (ntre_ridgeline_ctg ladder
  1, a bot 17 u under the floor, the Assault crouch height).

The hull now stands on the floor under the spot, and a spot still in
solid is skipped, so the bot climbs from where it is.

Measured (12 bots, 8 wide): rise ladder 6 up, 22 of 51 teleports took
the fallback into the brush and 2 of 16 runs lost a bot there for the
round; after, 23 teleports skipped, 0 of 24 runs, up climbs reaching
the top 17 of 23 -> 24 of 25. ridgeline: 0 traps in 80 runs, up
climbs completed 161 of 168.
The ladder approach waited while the round's pre-round freeze time
was left, but that time is also counted at warmup, at the ready-up
countdown and while the server idles, when nobody is frozen: a bot in
a ladder approach then stood still for the length of the freeze.

Test the bot's own NEO_FL_FREEZETIME flag instead, as the locomotion
and the breakable-clearing behavior do. It is set exactly while the
bot cannot move, so the wait is unchanged in the pre-round freeze.
HandleUnwantedLadder() looped over every nav ladder on every
locomotion update a bot spent on a ladder it did not ask for, though
only the adoption at the end of a half-second bout needs to know which
ladder it is. Run the scan after the bout test instead.

The caller lets go of the ladder whenever this returns false, before
and after. The one difference: a contact the scan matches to no nav
ladder now counts toward the bout, so a bot that slides from such a
spot to a recorded ladder within one bout is taken over sooner.
IsLadderCapClimb() answered yes or no, and its caller then took the
cap area from the path again, relying on the test having checked it.
LadderCapAhead() returns the area itself, or NULL, so the launch uses
the area the test examined.

Say what the test measures (an axis-aligned depth along the approach)
and how it differs from the dismount's IsNarrowLanding(), and that the
repeated launch lines are kept apart from the authoritative branch on
purpose. No behavior change.
Climbing() runs on every path update, and the climb onto a wall cap
by the mesh link is gated only by the goal and the bot's range to the
launch point: a bot that launched and fell back to that point jumped
again on every landing until the path was planned again.

Count the launches for the current path segment and, after three,
leave the climb to the ordinary ledge search. The count starts over on
a new goal segment and on every new path. The ladders that take this
link most (rise L4 down, saitama L11 down, dawn and dusk L2) are the
re-measure set for this change.
Since the dismount aims at a landing's near point, m_exitAreaCenter
holds the exit area's center only for a narrow landing, and lines such
as zDistToExit = currentZ - m_exitAreaCenter.z read as the center.
Call it m_dismountPos. No behavior change.
Review edits, no behavior change:

- The climb's IsNarrowLanding() and the path follower's
  LadderCapAhead() say what each measures: a landing under half a hull
  is crossed, not stood on, while a top up to a hull deep still holds
  the bot's center though the ledge search misses it.
- The teleport's old spot is named as the unchecked fallback, with its
  gap as LADDER_PLACE_GAP, and the floor-trace comment reads in order.
- Constants give their origin: NARROW_LANDING_HULLS (dawn and dusk L1,
  climbs ending at the exit 16 of 21 with it, 8 of 24 without),
  LANDING_INSET (half a hull), STALL_TIME (ridgeline L1, approach
  timeouts 15 -> 0), STALL_NUDGES (subsurface L1 up 34 -> 72 %,
  saitama L2 rim snags 21 -> 0), LADDER_MIN_DESCENT_RATE (a view
  55 degrees below level) and LADDER_CATCH_OVERLAP (twice the grab
  reach); the descent test cites LadderMove().
- Approach() drops the empty hold branch: both of its moves now skip
  the hold, which presses nothing as before.
- LadderEndArea() names the ladder end the climb leaves by, two
  traces wrap at the mask, and m_nNudges joins the constructor list.
A bot that climbs down a ladder whose foot hangs above the floor drops
onto the floor crouched. If it then heads back up, the crouched hull
fits under the ladder's bottom, so it never touches the ladder and the
approach times out and restarts in place, for up to 40 s on
sentinel_ctg's 75 u stub ladders.

Going up such a ladder, release the crouch, and while still crouched
step out to just in front of the face, where the bot stands and can
press onto the ladder.
@sunzenshen sunzenshen changed the title Fix bots bouncing on ladders and failing to descend Fix bot ladder approaches, climbs and dismounts Oct 9, 2026

@sunzenshen sunzenshen left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For manual testing, the following script will stop any bots that are not under command (collected via the +use key, and moved via the +ping key), so one can take their time looking at the maps and ordering bots around different locations:

sv_neo_preround_freeze_time 2; mp_chattime 2;
sv_neo_bot_cmdr_enable 1;
sv_neo_bot_cmdr_debug_pause_uncommanded 1;
neo_ctg_round_timelimit 999;

Disclosure: This code was developed through an autonomous agentic testing loop, by overriding the spawn points of one of the teams onto nav areas near both the top and bottom of ladders, and setting their destination with a spawn overridden ghost objective near the opposite end of the ladder. The various edge cases in this PR were evaluated by checking hundreds of telemetry-injected logs of bots climbing or failing to climb ladders.

Previously the neo_bot_ladder_ implementations were developed also by me through LLM-assisted coding, but the wide spread of different permutations of ladders became too overwhelming to iterate through manual testing, hence this PR.

We probably want to put extra attention to the NextBot/ code files, since those are mixed with implementations original from the Source Engine SDK Base examples.

I wouldn't actually mind if someone else made an attempt at solving this problem with a fresh perspective, but I warn you that this path is paved towards madness.

Anyway, with this PR, bots are slightly more capable at climbing and descending ladders that have this kind of cursing overhang at the top:

Image Image Image

Comment thread src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp
Comment thread src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp

@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.

Let me know if this is a real problem; I'm calling it an inconsistency between the intents as written but it may ultimately be OK?

Comment thread src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp
Comment thread src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp Outdated
@sunzenshen
sunzenshen force-pushed the bot-ladder-unwanted-release-0927 branch from 04eb3b4 to eab8ed8 Compare October 12, 2026 04:09
The stall branch started each shimmy's timer at 0.3 s times the shimmy
count, but the progress check that starts the next shimmy or the
jump-off comes every STUCK_CHECK_INTERVAL (0.4 s) regardless, so the
shimmies ran 0.3, 0.4 and 0.4 s, not the 0.3, 0.6 and 0.9 s the comment
described. sunmachine's review of this PR found the mismatch.

Start every shimmy at the check interval instead, so the code says what
it does, and drop STALL_NUDGE_TIME. The measured behavior is unchanged:
the numbers cited for this change were taken with the 0.3 / 0.4 / 0.4 s
shimmies.

Measured against the growing version (the stuck check following the
shimmy timer, 0.3 / 0.6 / 0.9 s) and the code as it was, Release builds
on the rebased branch: subsurface ladder 1 up and saitama ladder 2 down,
8 runs an arm, and 20 ladder directions at the snag sites (dawn, dusk,
rise, saitama, sentinel), 3 runs an arm, 312 matches. A second or third
shimmy happened in under 5 % of stalls; where it happens most, the top
of rise ladder 3, every arm ends in the jump-off. Climbs finished,
near-ladder stuck events and falls did not differ between arms.
@sunmachine
sunmachine self-requested a review October 12, 2026 05:41
@sunzenshen sunzenshen changed the title Fix bot ladder approaches, climbs and dismounts More tweaks to bot ladder approaches, climbs and dismounts Oct 12, 2026
@sunzenshen
sunzenshen merged commit 092203f into NeotokyoRebuild:master Oct 12, 2026
12 checks passed
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 13, 2026
neo#2176 merged at 092203f with every follow-up of the research
branch's ladder PR. The ladder files (NextBotPlayerLocomotion,
neo_bot_ladder_approach, neo_bot_ladder_climb) are a 3-way merge:
research tip b808069 over the PR state f710510, onto the merged
code. Research arms the merged PR now does by default are removed:
neo_bot_ladder_exit_closest (+ the thin-exit rule), _teleport_reach,
_teleport_view, _flush_top, _catch_above_top, _stall_nudge, and the
dead patch 109 cvar neo_bot_precise_move_direct. The TEMP NeotokyoRebuild#2176 A/B
switch (neo_harness_ladder_pr_off / _descent_off) is removed: it chose
pre-NeotokyoRebuild#2176 code that no longer exists. Patch 75 (lateral hold) and 77
(teleport grab) stay layered on the merged nudge and teleport.

Folds the ladder-file hunks of these research commits:
  557d58b Ladder research: claim ladders for PlayerLocomotion, mount hop, dismount exit, telemetry
  5173fde Ladder research: shimmy past a snag before abandoning a climb
  71481eb Ladder research: mount side-landing ladders from in front when going down
  3cf88a4 Ladder research: side-landing descent walks off the landing before pushing in
  09262ba Ladder research: finish a ladder past halfway instead of jumping off at a threat
  f0e786a Ladder research: hold a climbing bot inside the ladder's width
  d0c5704 Ladder research: make the climb's MoveUp / MoveDown presses reach the user command
  02b99aa Ladder research: a claimed ladder the bot is already on is never 'too far below'
  e88b779 Ladder research: keep a teleported climb on the ladder
  d21eba5 Ladder research: aim the ladder approach inside its slot
  5807c4f Ladder research: drop off a descent only below the top floor
  691ffe0 Ladder research: press the climb's direction at the mount
  8cb511d Ladder research: a bot already on the ladder starts to climb
  c4264ff Ladder research: mount every descent from the front
  20b78da TEMP: ladder transition log and the NeotokyoRebuild#2176 A/B switch
  8f6572a TEMP: gate all of NeotokyoRebuild#2176 behind the ladder switch
  687a619 Fix the research port: a stray brace and the patch 109 twin
  5e7595a Research: turn the ladder stall nudge back on
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 15, 2026
…embers)

Both this branch and NeotokyoRebuild#2176 add constants, constructor lines and header
members next to the same places in NextBotPathFollow; keep both.
sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 15, 2026
…embers)

Both this branch and NeotokyoRebuild#2176 add constants, constructor lines and header
members next to the same places in NextBotPathFollow; keep both.
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