Repository navigation
Bots climb down ladders instead of bouncing off the top - #2187
Closed
sunzenshen wants to merge 6 commits into
Closed
sunzenshen wants to merge 6 commits into
sunzenshen wants to merge 6 commits into
Conversation
CGameMovement::LadderMove() puts a player on any ladder their wish direction points at, while PlayerLocomotion::TraverseLadder()'s NO_LADDER branch drops the move type straight back to walking whenever the bot's own state machine did not ask for a climb. A path that merely brushes past a ladder leaves the two toggling every tick: the bot hangs there making no progress, and because it never stops moving, no stuck event fires to notice it. Ladders whose foot hangs a chest height or more above the floor they serve are the worst, since they lie across a walkway rather than sitting at the foot of one. Rather than let go harder - which does not work, the wish direction still points at the brush - the bot gives up the argument and rides the ladder out by whichever end is nearer, but only once the contact has persisted for half a second. Most grabs sort themselves out sooner than that and adopting those costs a lot of climbing nobody asked for. Measured over 213 scenarios on all 29 maps, 213 matches an arm: time pinned against an unwanted ladder 10.2 -> 6.8 s a match, worst bout 61.7 -> 21.4 s, climbs finished 71 -> 79 %, stuck events 155 -> 141, capture rate level at 25 %, for 3.5 s a match of adopted climbing. Two held-out batches on five maps the behaviour was never tuned against, 156 matches an arm, agree: pinned time -37 %, worst bout -41 %, and the most finished climbs of any variant tried. Taking the ladder over only upward, and taking it over on contact without waiting, were both measured and are worse - the first strands bots somewhere they have to come back from, the second re-adopts the same ladder about six times a bout. This lands as a plain behaviour change with no convar to switch it off. The bug is not conditional: a reviewer running the branch with the fix disabled would simply hit the original defect and judge the branch by it, and offering the choice would ask them to review a switch as well as a change. The strategies that lost are in this branch's history and in the research notes rather than in the shipped code.
CNEOBotLadderClimb puts the bot on a ladder without telling PlayerLocomotion, whose ladder state stays NO_LADDER. The locomotion then treats the climb as a ladder it never asked for: it forces the move type back to walking every update, and after half a second the unwanted-ladder takeover adopts the ladder by its nearer end. At the foot of a ladder that end is the bottom, so a bot mounted to climb up is driven back down. The climb now calls ClimbLadder() or DescendLadder() when it starts, so the locomotion drives the same way as the behavior. The dismount goal is the climb's exit area, else the ladder's own end area; without one there is nothing to walk to and the climb is left unclaimed. ApproachAscendingLadder() checked whether the bot is too far below the nav ladder's bottom before checking whether it is already on the ladder. On a ladder whose foot hangs above the floor that refused a claimed climb and handed it to the takeover again. A bot already on the ladder now goes straight to ascending. Research arm, 13 ladder-heavy maps: up-climbs finished 54 -> 66 % with the claim; the reordered check fixed 29 of 96 refused claims on a ladder with a 55 u foot gap.
PlayerLocomotion's AscendLadder() and DescendLadder() give up the ladder the moment the engine reports the bot off it, even for a single tick. When CNEOBotLadderClimb carries on and the engine grabs the bot again, the locomotion sees a ladder it never asked for, forces walking, and after half a second adopts it by its nearer end - mid-climb often the bottom. While it climbs, the behavior now claims the ladder again whenever the bot is on it and the locomotion has let go. The dismount phase is left alone: there the bot is meant to be leaving the ladder. Research arm, measured together with a fix to the climb's teleport placement, on a 32 u squeeze ladder whose climbs lost their claim this way: finished up-climbs 59 -> 76 % by position.
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.
A bot the engine puts on a ladder while it still stands at the top - on the landing, or on top of the ladder brush where that stands a step proud of the floor - cannot press its way down: the floor or the brush is under its feet. The approach used to mount it there anyway. - The approach heads for where a descending bot hangs, half a hull plus 2 u in front of the ladder's face at its top, and mounts only once the bot is off the top: below the ladder's top and no longer standing within a step of it. - It lines up on the ladder's normal line only from in front of the face. Coming in at an angle, the bot stays on the floor beside the drop and steps up onto the ladder's top; from beside or behind the ladder the line-up would aim into the wall, so it walks straight on. - Going down it looks down the ladder, not level at its center: the forward press that grabs the ladder stays held until the next bot update, and with a level view it climbs the bot back up.
CNEOBotLadderClimb jumps off early once the exit area is within a safe drop below. Going down a short ladder that is at once, on the mount, and the kick towards the exit area's center lands the bot back on the floor it started from wherever that floor overhangs the exit (a hatch in a roof, a landing over the room below). Going down, the jump-off now waits until the bot is a standing height below the ladder's top.
This was referenced Oct 1, 2026
Contributor
Author
|
Closing this in favor of combining with #2176, as these both cover the same ladder testing case and should be reviewed together. |
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.
sunzenshen
added a commit
to sunzenshen/neo
that referenced
this pull request
Oct 7, 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.
sunzenshen
added a commit
to sunzenshen/neo
that referenced
this pull request
Oct 8, 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.
sunzenshen
added a commit
to sunzenshen/neo
that referenced
this pull request
Oct 8, 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.
sunzenshen
added a commit
to sunzenshen/neo
that referenced
this pull request
Oct 8, 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Bots climb down ladders instead of bouncing off the top
Depends on #2176 ("Stop bots fighting the engine over unwanted ladders") and is stacked on its branch; review the
last three commits. With #2176 the climb behavior claims the ladder for
PlayerLocomotion, whose descend states thensteer every descent the behavior starts. This PR makes those states, and the two ladder behaviors around them, take a
bot down.
A bot that starts down a ladder from its top usually ends up back on the floor it started from, and tries again.
ntre_saitama_ctg's elevator hatch shows it most: 95 % of the descents of the ladder in the hatch end back on the carroof, about 25 attempts a match, with or without #2176 (94 % on upstream's ladder code). The bots then walk a
floor-level path on the roof into its walls, the map's largest stuck spot. Three things put them back on top:
CGameMovement::LadderMove()turns the part of a move that pushes into the ladder's faceinto climbing up, and moves the player along the face with the rest. Facing the ladder, forward goes down only
while the view points more than 45 degrees down; with the view level it climbs. Every descent pressed forward before
the view was down: the approach's last forward press stays held until the next bot update, after the grab;
ApproachDescendingLadder()kept steering towards its mount point after the bot was already on the ladder; andApproach()presses forward on any ladder the bot is descending.DescendLadder()aimed away from the face whilethe climb behavior aimed into it, both at MANDATORY priority, and
PlayerBodyrefuses a same-priority aim until thehead has been steady for 0.3 s, so the view was slow to come round at all. Near the top, a few ticks of climbing
lift the bot past the ladder's top and off it.
on the landing or on top of the ladder brush, which is solid and often stands a step proud of the floor (the hatch
ladder's top is 14 u over the roof). From there no press takes the bot down: into the face pushes into what it
stands on, and a move away from the face on a floor makes
LadderMove()push it off the ladder.CNEOBotLadderClimbjumps off as soon as the exit area is within asafe drop below, which on a short ladder is at the mount, and kicks the bot towards the exit area's center. Where
the top floor overhangs the exit (the roof over the car), that lands it back on the top floor.
Changes, in three commits
NextBotPlayerLocomotion.{h,cpp},neo_bot_ladder_climb.cpp).PlayerLocomotion::IsForwardDownLadder()says whether a forward press moves the bot down, from the view and the faceit holds (the ladder brush's back face when it grabbed the ladder from behind); a press facing away from the face does
not count.
Approach()presses nothing on a descent until it does, andDescendLadder()looks down into the facerather than away from it.
ApproachDescendingLadder()passes a bot already on the ladder straight toDescendLadder(), before its "within a jump of the bottom" check lets go of it at the top of a short ladder. Theclimb behavior gates its own forward press 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.
neo_bot_ladder_approach.{h,cpp}). The approach heads for where adescending bot hangs, half a hull plus 2 u in front of the face at the ladder's top, looks down the ladder rather
than level at its middle, and mounts only once the bot is off the top (below the ladder's top, and no longer standing
within a step of it). It lines up on the ladder's normal line only from in front of the face, where coming in at an
angle leaves the bot on the floor beside the drop, stepping up onto the ladder's top; from beside or behind the
ladder it walks straight on, over the top.
neo_bot_ladder_climb.cpp). Going down, the jump-off waits untilthe bot is a standing height below the ladder's top.
No convar switches any of this off. Climbing up is unchanged.
Evidence
Upstream at #2168 plus #2176 and the draft SeekWeapon PR (#2186), with harness logging added and a test-only switch
for this change; both arms run the same server build. 30 bots, default weapon damage, 8 matches at a time; each run is
paired with a run of the other arm on the same map, ghost spawn and repeat, back to back. CTG: 2 rounds of 3 minutes with
the ghost pinned at one ghost spawn; TDM and JGR: one 5-minute round. All 384 matches below reached match end with 0
asserts and no crash. "Stuck" is
OnStuckplusFAIL_STUCKmove failures, without the round-start spawn crowd andweapon scavenging; intervals are 95 % bootstrap intervals of the paired ratio. A descent is a climb down the behavior
starts; it "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 the reworked one from thecompanion neoAssets PR (NeotokyoRebuild/neoAssets#146):
FAIL_NO_PATH_EXISTSthis PR.
map's other spots, on master's navmesh mostly its 3492 / 3493. Capped at 20 stuck events a bot, the total is x0.74
[0.62, 0.88].
0.57]): the bots that PR keeps out of the fire pit no longer end up in this trap, which its own measurement showed as
a rise in other stuck (x1.27 [0.98, 1.67]).
Other ladder maps, 72 pairs: 5 ghost spawns twice on transit, tarmac, subsurface, marketa, rogue and ghost CTG,
yard JGR's 3 juggernaut spawns twice, 6 bullet TDM matches (the navmeshes of their companion or planned neoAssets PRs;
ghost and yard on their current research navmeshes):
FAIL_NO_PATH_EXISTSBy map (10 pairs; 6 for yard and bullet), every interval across 1: yard x0.57, bullet x0.72, subsurface x0.77, rogue
x0.82, marketa x0.83, tarmac x1.10, transit x1.25, ghost x1.27. Ghost has no ladder descents; its figure is one
match in which three bots logged 68-138 stuck events each (one of those spots piles up the same way in the #2176
arm). On transit the descent of ladder 2 now works (8.7 attempts a match, 94 % back on top, -> 2.6, 0 %), and more
bots reach the pilot car below it, a known stuck spot (625: 27 -> 63 events in 10 matches).
Descents from behind the ladder's plane.
ntre_bullet_tdm's shaft ladders have forward tops, landings behind theladder's plane, which its reworked navmesh keeps as climb exits only, because descents from there failed.
On master's navmesh (8 matches an arm, one round each): ladder 1 19 of 20 descents back on top with #2176, 3 of 5
with this PR; ladder 4 27 of 27, then 0 of 8.
Cost
Two dot products per bot update while a bot descends a ladder.
Risk
Moderate. Every ladder descent changes: the approach now takes the bot to the point it hangs from and mounts only once
it is off the top, and the locomotion holds still until the view points down. Where the walk out to that point is
blocked, the approach's existing 3 s timeout ends it, as before.
Toolchain
Linked Issues