RPG Playability
This document is the permanent contract for direct commander control: what playable means, how the mode is validated, and which runtime rules make the result reliable. tools/arena/rpg_gate_manifest.json is the live record of scenario status.
The target is not an RTS camera placed close to a commander. Direct control is expected to provide the control, camera, movement, collision, animation, combat, and frame-pacing quality of a good third-person Souls-like action game while remaining inside Standard of Iron's large RTS world.
The dependency order is strict:
truthful diagnostics
→ authoritative input and presentation
→ stable motor and locomotion
→ collision-safe camera
→ mechanically correct combat
→ readable feedback
→ content, abilities, and spectacle
New abilities, enemy types, VFX layers, camera impulses, HUD flourishes, and cinematic behavior do not take priority over movement, camera, or combat correctness. Presentation must not hide a mechanical defect.
Product contract
"Souls-like" means the following here:
- Camera input is immediate and predictable. The view rotates only from player input, an explicit lock-on rule, or a collision-safety correction.
- Movement has weight without input ambiguity. Starts, stops, reversals, wall slides, slopes, and body contacts remain continuous and repeatable.
- The body, animation, camera anchor, crosshair, hit trace, and hurtbox agree on the commander's presented pose.
- Attacks have explicit startup, active, recovery, buffer, redirect, and cancel windows. Commitment comes from authored rules rather than latency or stuck state.
- Visible weapon contact determines a hit, and one contact applies damage once.
- Guard, perfect guard, dodge invulnerability, posture, and stamina use authored windows with one source of truth.
- Lock-on is stable, overridable, and subordinate to deliberate look input.
- Crowd pressure remains fair: active attackers are budgeted, off-camera pressure is limited, and attacker slots rotate.
- Stable 60 fps frame pacing on declared reference hardware is part of control quality.
Validation harness
make rpg-gate
make rpg-gate RPG_GATE_ARGS="--skip-build"
make rpg-gate-baseline
scripts/run-rpg-gates.sh --scenario rpg_locomotion
scripts/run-rpg-gates.sh --enforce-performance
The gate builds arena_app, app_tests, and arena_tests, runs the unit-test filters named by the manifest, then runs each manifest scenario sequentially through the real renderer into a fresh artifact directory. The summary reports scenario, owning gate, expectation, repeats passed, completion, verdict, issue codes, frame-time statistics, and artifact path. Any mismatch exits nonzero.
Rendering requires a display; the script defaults DISPLAY to :0. Full sweeps suspend X11 blanking and DPMS for the duration of the run and restore the original settings on normal exit or interruption. Scenarios are never run concurrently because multiple renderer processes contaminate frame timing and can turn resource starvation into misleading watchdog failures.
Artifacts
Each scenario writes report.json, run_config.json, its log, and any captures. run_config.json includes commit identity, dirty-worktree state, build type, CPU model, GPU vendor and renderer, GL version, OS, kernel, and viewport size. The run root also contains gate_run.json and machine-readable gate_summary.json.
Artifacts live under artifacts/rpg-gates/. The comparison baseline is artifacts/rpg-gates/baseline.
Scenario manifest
tools/arena/rpg_gate_manifest.json is authoritative for the rpg_* scenarios included in the gate. arena_rpg_gate_manifest_test compares the manifest and scenario registry in both directions, so neither side can silently drift.
| Field | Meaning |
|---|---|
id |
Registered scenario id |
status |
required_green or expected_red |
gate |
Playability gate that owns the scenario |
notes |
What the scenario proves or why it is intentionally red |
issue_codes |
Required failure codes for expected_red |
repeats |
Number of identical runs used for the verdict |
reproduction |
deterministic or nondeterministic |
intermittent |
Whether mixed repeat results are an explicitly declared property |
expected_red is a ratchet, not an exemption. If every repeat passes, the gate reports FIXED and exits 4; the same change must promote the scenario to required_green. Thresholds are changed only for an explicit design reason, never simply to make a row green.
Verdicts
Completion, behavior, and performance are separate axes.
completed: false means cancellation, watchdog termination, crash, or missing report. It is INCOMPLETE, or TIMEOUT when timeout.txt identifies the wall-clock watchdog, and it always fails the gate. Manual takeover is explicitly marked cancelled: true.
Behavior covers every non-performance issue code and is hardware-independent enough for normal CI enforcement.
Performance covers frame_budget_exceeded, the performance_* codes, and frame-time percentiles. It is always reported and is enforced only with --enforce-performance on named reference hardware.
| Repeat result | required_green |
expected_red |
|---|---|---|
| All pass | PASS |
FIXED |
| All fail | FAIL |
RED(known) |
| Mixed | FLAKY-FAIL |
RED(intermittent) when declared, otherwise FLAKY |
A nondeterministic reproduction records a real defect that the scenario cannot provoke on demand. It reports RED(unreproduced) on a passing run and RED(known) on a failing run, but it does not by itself change the gate result. It remains an explicit debt until a deterministic reproduction exists.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Every scenario matched its manifest expectation |
| 1 | A required_green scenario failed behaviorally |
| 2 | Build, unit-test filter, or argument failure |
| 3 | Incomplete run, timeout, or missing report |
| 4 | An expected_red scenario passed every repeat |
| 5 | Enforced performance budget missed |
| 6 | Identical repeats disagreed without an intermittent declaration |
Presentation trace
Every rendered frame of an rpg_* Arena scenario carries a commander object in trace.jsonl. Tracing is Arena-only and opt-in through ArenaViewport::set_presentation_trace_enabled(true).
The trace is designed to attribute a failure to one pipeline stage rather than infer it from the final image.
| Group | Contents |
|---|---|
input |
Edge sequences for press, release, consumption, refusal, and drop; sampled frame; move axes; held state and duration; raw look delta; view angles |
motor |
Authoritative and presented pose, desired and accepted velocity, grounded state, blocked/slide state, separation correction, lunge, jump snap-back, and displacement sources |
camera |
Commander and visual anchor, pivot, unconstrained and resolved eye/target, boom state, obstruction, terrain lift, eye clearance, FOV, yaw/pitch, framing, and framing transitions |
combat |
Action phase and time, queue state, guard and perfect-guard state, dodge timing, target ids and slots, hit-confirm sequence, health, and stamina |
costs |
Scoped timing accumulators for motor, targeting, weapon trace, engagement, and camera |
The motor trace also carries the shared movement facts that identify ownership in one line:
| Field | Meaning |
|---|---|
movement_mode |
direct_control or rts |
steering_source |
DirectControl, Route, or None from MovementFacts::desired.source |
static_walkable |
Result from the shared Walkability layer |
dynamic_push |
Correction applied by BodyContactSystem, with neighbor and overlap counts |
accepted_displacement |
Actual displacement compared with requested speed and dt |
dynamic_push comes from shared movement facts rather than a controller-local estimate. A nonzero push in direct control with a steering source other than DirectControl is therefore an ownership violation. separation_push remains the magnitude used by CommanderMotorCorrectionWithin.
Animation data stays in the existing soldiers samples, including visual state, transition counts, gait diagnostics, reaction data, and planted-foot positions. Frame objects carry frame_time_ms and the CPU/GPU phase breakdown.
Standard expectations
Arena expectations convert traces into explicit pass/fail rules. The standard commander checks include:
| Expectation | What it enforces |
|---|---|
CommanderInputEdgesAllConsumed |
Every relevant press is consumed or explicitly dropped, and drops remain within budget |
CommanderBoomIsContinuous |
Retraction may be immediate; extension must remain smooth and must not pump under one obstruction |
CommanderMotorCorrectionWithin |
Separation correction and jump snap-back remain inside their per-frame budgets |
NoUncommandedViewRotation |
The view changes only from look input, framing, or an active lock |
CommanderSpeedIsContinuous |
Planar velocity changes remain inside acceleration/deceleration budgets |
CommanderContactCountAtMost |
One running action produces no more contacts than authored |
CommanderCameraKeepsCommanderInSight |
Opaque geometry cannot remain between the lens and commander beyond the allowed run |
Each expectation has synthetic positive and negative coverage in ArenaCommanderMetricsTest. Retraction is immediate: easing it allowed the lens to enter a wall during a sharp turn. Outward release remains damped and bounded in metres per second. The close-quarters regression checks clearance on the first frame of a turn at 30, 60, and 144 Hz, including a facade closer than the preferred minimum boom. Tiny boom reversals below 5 mm are numerical noise rather than pumping.
Per-frame geometric budgets scale by frame_time * 60, with a minimum scale of 1, so the same physical-rate limit applies to ordinary frames and authored hitch frames.
Input ownership
Physical input has one authoritative path from the GUI into the simulation tick. Input handlers only record state and edges; they do not execute combat or guard state directly against the world.
CommanderInputSnapshot is the render-to-simulation packet. CommanderControlController::take_input_snapshot() runs once per tick under m_input_mutex, copies the nine held booleans, and moves the six edge latches out of the producer. Simulation-side code reads m_tick_input; InputState is written only by input handlers using the same mutex.
primary_action is private to the controller path. A press with no immediately actionable body state is carried explicitly in m_carried_primary_press, so a held press can survive a dodge or another temporary inability to act without becoming an unaccounted edge.
View angles are intentionally outside the input mutex. m_view_yaw and m_view_pitch participate in input, camera, lock-on, and simulation paths; locking them would put the camera path behind the input mutex. Edge latches, where loss is possible, remain synchronized.
Focus loss, menu transitions, Arena focus-out, and interactive exit all route through release_all_input(). This releases held movement and mouse actions rather than dropping a local key map while leaving simulation state held.
Arena scripted attacks use real press edges. Interactive takeover keeps the viewport as the raw-key owner by making ArenaViewport::focusNextPrevChild refuse Tab focus navigation. With SOI_ARENA_RPG_TRACE=1, interactive mode logs commander position, view angles, and input-edge counters once per second so manual takeover remains observable.
Interactive takeover cancels the scenario runner before the next scripted command. Cancellation freezes its report and does not count as completion or success; the world continues to simulate for manual play. Scenario fixture initialization is separate from control activation, so entering control does not refill stamina or clear posture and guard-break penalties.
Soldier diagnostics record the selected animation clip as well as its semantic
state. The hold-pose check accepts the authored kneeling bow attack even though its
state is AttackRanged, and still rejects ordinary standing attacks.
Presented pose
The simulation ticks at 60 Hz while presentation can run at a different rate. CommanderPresentationSampleComponent carries the authoritative previous and current pose, tick duration, tick sequence, and the resolved presentation sample used by both camera and renderer.
Presentation age is measured from the previous sample's tick. On a new sequence, the resolver subtracts the tick that just ended rather than resetting the clock. A frame on a simulation tick reads the authoritative pose at alpha 1; a frame between ticks may extrapolate into the current tick with alpha in [1, 2), subject to the resolver's cap.
The camera is the presentation authority for the resolved commander pose. It publishes presented_position, presented_yaw, and presented_valid on the sample, and the renderer reads that exact pose. instance_prepare builds unit_base from the published presentation pose rather than independently aging another clock or reading raw TransformComponent.
Renderer::reset_animation_time() runs when a batch scenario is loaded under fixed-step execution, so the first clip sample is deterministic across repeated runs.
ArenaScenarioDefinition::presentation_hitches supplies {at_seconds, frame_ms} entries that replace one fixed-step frame at or after the authored time. This feeds the long frame through simulation, controller, camera, and trace exactly like a real hitch.
CommanderPresentationPoseTest.PresentedSpeedIsUniformAtEveryDisplayRate and UnitRenderCacheTest.ThePresentedStepIsUniformAtANonMultipleDisplayRate cover non-multiple display rates including 72, 100, 144, and 165 Hz.
Shared movement
Direct control produces steering intent; it does not own collision policy. Game::Systems::body_profile_for(), Walkability, and BodyContactSystem decide where the commander may move. They form the same shared layer used by RTS-controlled bodies.
The commander's centre is tested the same way in both control modes (motor_profile_for): the nav cell under it must be open, and its facade distance stays person-scale. DirectControlPassesEveryLaneAnRtsOrderPasses walks a one-cell lane 0.3 m off its centre line. That is the gap between two hills an RTS order crossed while direct control refused it. See "One Ground Rule, One Sealed-In Decision" in PATHFINDING_ARCHITECTURE.md.
Dynamic contact has three important properties:
- Contact resolves pairwise against one body per entity rather than every formation anchor.
- All pairs share the per-tick correction budget defined by
k_separation_speedandk_max_separation_step. - A body that is already moving receives its contact correction sideways, so traffic deflects motion rather than simply braking it.
CommanderSharedTraversalTest requires dense friendly traversal to retain at least 60% of unobstructed progress and requires an idle commander inside friendly ranks to remain within one metre of the starting point.
Commander motor
The direct-control motor stores planar velocity as a vector, so a direction reversal cannot preserve scalar speed while flipping direction in one tick.
k_commander_ground_acceleration_mps2 is 30 m/s² and k_commander_ground_deceleration_mps2 is 36 m/s². A full reversal passes continuously through zero. A blocked step adopts zero accepted velocity, and a slide adopts the accepted slide velocity, preventing stored perpendicular speed from reappearing when contact ends.
fpv_motion_requested follows smoothed accepted speed rather than the raw motor.blocked flag, because tiny repeated facade contacts can legitimately alternate the blocked bit while the presented gait should remain stable.
The direct-control movement-state floor is CommanderComponent::k_direct_control_gait_floor_speed at 0.60 m/s. Published component velocity, per-tick displacement, and fpv_motion_requested all honor that floor during coast-down, allowing locomotion presence to fade while the motor finishes braking.
A step that is blocked straight ahead and on both axis slides is retried at 30° and then 60° to either side. Its length is scaled by the cosine of the angle, so the deflection never moves the commander faster than he asked to go. A one-cell boulder or a trunk met head-on is therefore skirted rather than stopping him dead; at Trebia he stood against a bank boulder for eleven seconds with forward held. A wall met square-on still stops him, because every rotated step still points into it. WalkingStraightIntoABoulderGlancesAroundIt covers this.
The direct-control sprint target is exactly 2.5 times unit->speed through k_fpv_walk_speed_scale and the run multiplier. Movement continuity thresholds therefore include explicit headroom rather than relying on asymptotic approach to top speed.
Person-scale geometry
RTS navigation footprints and person-scale collision bodies are separate concepts.
get_building_body() provides the body extent used by the commander, camera obstruction, bow aim, melee line of sight, and other person-scale queries. BuildingFootprint carries body_center_*, body_width, and body_depth, rotated with building facing at registration. RTS navigation footprint, grid padding, and route behavior remain separate.
BuildingBodyFootprintTest rebuilds building geometry through a recording submitter and requires the person-scale body to cover the rendered span without unnecessary padding. It also requires the person body and navigation footprint to remain distinct.
The barracks navigation footprint is smaller than its rendered geometry; that is an RTS pathing issue and is not corrected by the RPG person-scale body.
Bridges and wall walks
Bridge parapets are solid to a person-scale body. The navigation grid marks the whole deck width, parapets included, as walkable, so a direct-control commander could walk 3.2 m off the centre line of an 8 m bridge with the stone rail through his body. Walkability adds Game::Map::bridge_parapet_penetration to the facade test, using the same parapet offset and thickness as the bridge mesh (bridge_parapet_centre_offset, bridge_parapet_half_thickness). RTS routing is unchanged.
A bridge spans exactly one river. fit_bridge_span_to_riverbanks refits every authored bridge to the first river it crosses, so a bridge drawn across two channels is cut back to the first one. At Ticino the bridge at grid (338, 416) landed on the strip between two channels about 7 m apart and was a dead end on foot; the map now authors a second bridge, (337.5, 422.6) to (336.9, 430.2), over the southern channel.
A direct-control commander opens his own gate by walking at it. GateSystem opens a gate for a unit inside its trigger radius that is not loitering, and a unit with no move target counted as loitering. Direct control steers without a move target, so the gate stayed shut and the commander walked into it. A commander with fpv_controlled and fpv_motion_requested set is now treated as moving. An enemy at the gate still bars it, as it does for every unit. A gate's person-scale body is its two towers (gate_tower_bodies, the 5.7 m passage between them left open), both for the commander's facade test and for the camera. The building body of a gate was a 3 m box in the middle of its own passage, so a commander who walked straight through his open gate stopped dead in it, and a lens standing in the passage counted as buried inside the gate. While the gate is shut (it has a live GateService blocker), the camera gets a 0.6 m thick door slab across the whole passage, so a commander at his own closed gate is never framed from the far side of the doors. The slab spans the full passage width, not the blocker's box: that box is the one-cell navigation lane, a metre wide, and a commander who stopped just outside beside it had the gate shut behind him with the lens still in the passage, filling the screen with the leaves.
A direct-control commander can climb his own side's walls. App::Core::WallDeck describes the standing surfaces of every wall segment within 14 m:
- the deck strip, 1.05 m either side of the node along the wall and 0.56–1.22 m inward, at
WallWalk::k_deck_height; - the stair of a stair node, on the commander's side or an ally's;
- a standing ladder, on any wall.
Stairs and ladders are ramps from the ground at their foot to the deck height at the deck edge.
CommanderMotor::advance tries the wall first. Stepping onto a stair or ladder within 0.45 m of the ground boards the wall: the commander gets a WallWalkerComponent with player_driven set, and TerrainAlignmentSystem lifts him by its elevation exactly as it lifts an RTS garrison. While he is aloft, each step must land on a deck or ramp surface. Otherwise he slides along one axis, or, if a push left him off every surface, he is put back on the nearest deck point. Walking back off the foot of a stair returns him to ground movement. WallWalkSystem skips a player-driven walker while the commander is under direct control. When control returns to the RTS, it takes him over on the deck, or drops him if he was low on a stair. When the player takes control again, an RTS walker already aloft is adopted rather than left to fight the ground motor.
Locomotion continuity
Persistent gait state is idempotent for a repeated sample time. resolve_humanoid_locomotion_sample() does not advance or overwrite persistent state when sample_time has not moved beyond previous.last_sample_time, so preparing one frame more than once cannot produce an extra decay step.
SoldierAnimationDebugSample exposes locomotion_blend, locomotion_presence, cycle_phase, persistent_valid, previous presence, sample time, and foot positions. These values make a stop or transition diagnosable from one trace rather than from visual inference.
k_planted_foot_height is 0.05 m. A foot above that contact window may move freely; a grounded foot is subject to the sliding expectation.
Direct control switches the presented gait to idle once accepted speed falls through the 0.60 m/s gait floor while locomotion_presence eases out. A held movement input still counts as movement during startup.
Side-step clips
Direct-control strafing uses authored clips rather than rotating procedural stride math at runtime. The baked set is:
walk_strafe_leftwalk_strafe_rightrun_strafe_leftrun_strafe_right
resolve_locomotion_crossfade selects them from the lateral share of travel and blends them against the fore/aft clip. resolve_humanoid_locomotion_pose lays the stride on the travel axis in body space, widens the stance along that axis, leans the torso into the step, and reduces fore/aft arm swing and torso twist.
turn_amount and travel_lateral remain separate. Ordered RTS units normally turn toward travel; a direct-control commander may sustain a facing/travel mismatch as an intentional side-step. Strafe clips are therefore offered only when VisualMovementState::facing_independent_of_travel is set from MotionPresentationSource::DirectControl.
foot_turn_scale remains a turn mechanic and is not applied as a sustained strafe modifier.
A full eight-way authored locomotion set and a dedicated metric for foot slip during locomotion remain open content/validation work.
Camera collision and obstruction
camera_obstruction resolves boom clearance, body clearance, and depenetration against buildings and every solid world prop. Prop obstruction data is indexed from TerrainService::world_props_revision rather than rebuilt every frame.
Buildings are treated as vertically blocking across the chase-camera range, except wall segments and gates, which block only up to the wall crest (WallWalk::k_crest_height, 2.95 m above the ground). A gate's towers and lintel stand higher than the curtain, so the gate towers block up to WallWalk::k_gate_crest_height (3.95 m). With the gate at 2.95 m, a commander standing just inside his own gate had the boom pass over it and the lens settle outside, with the lintel filling the lower half of the screen. A commander on a wall walk has his pivot above the crest, so the wall he stands on does not swallow the boom. Props use world_prop_occluder_height, including model scaling, so tall ruins and tents can retract the camera while low boulders, ore seams, and fallen logs below the eye do not.
Trees never shorten the boom. A trunk is slender and only crosses the sight line for a moment; when it was a collision box (its navigation footprint is at least a metre square), walking past a pine snapped the lens a metre forward in one frame, and a walk through a wood pumped the camera continuously. A stem still keeps the eye out of itself through depenetration, with a 0.30 m half-extent. Tree canopies are excluded as before.
When the boom on the current shoulder is less than 55% clear and the other shoulder, or the centre line over the head, is at least 30 points clearer, the rig moves the lens there, easing the side offset over about 0.2 s, and then keeps that choice for 0.6 s before it may change again. It returns to the right shoulder once that side is fully clear again. Without the dwell, a commander walking through his own gate had the two tower bodies trade the better shoulder every few frames and the lens jumped 2.7 m from side to side. Walking along a house or tent therefore swings the camera to the open side instead of driving it into the commander's head. Whenever the boom is retracted, the eye also rises by up to 0.75 m, in proportion to the retraction, so a close camera looks down over the head rather than through it.
The boom shortens before any sideways escape. Its minimum resolved length is 0.55 m rather than a fixed fraction of nominal boom length. When the resolved eye still overlaps a body, the rig first walks back along the boom in twelve steps down to that minimum and takes the first clear point, so the eye stays on the line behind the commander; only when no point on the boom is clear does it slide sideways. Sideways depenetration is therefore reserved for a pivot that is itself buried and may not increase planar distance from the commander. Face pushing used to come first, and at a box corner the nearest face changed from frame to frame, so the lens hopped between the two faces.
A prop body that already contains the pivot is not an obstruction: the commander is standing in it, so it cannot be a wall between him and the lens. Props keep a camera body larger than their navigation footprint (a statue's plinth, a boulder), and a commander fighting beside the statues on the Sepulcher Bridge's north bank had the boom report zero clearance and collapse every other frame. Buildings keep the buried-pivot rule above. A boom that passes above a prop's top enters the prop where it dips below that top, not all at once: the entry fraction is clipped against the top plane, so a lens that descends past a statue shortens continuously instead of jumping from full length to a sixth of it in one frame. ABuriedPivotNeverPushesTheLensAwayFromTheBody requires the resolved eye to remain within 0.75 m of a commander inside a temple footprint.
Terrain clearance is solved along the entire boom rather than only beneath the final eye. The lift required to clear ordinary terrain is capped at 2.5 m; steeper terrain shortens the boom instead of turning the chase camera into a high aerial shot. Terrain intersection uses an interpolated crossing between samples rather than snapping to the last clear sample.
Obstruction retraction is immediate. Extension is eased and capped at 6 m/s. Eye depenetration leaves a 0.14 m margin rather than resting exactly on the surface.
nearest_building_body_clearance() and the shared obstruction layer expose signed eye clearance in the camera trace. rpg_camera_prop_gauntlet, rpg_camera_wall_pocket, and rpg_camera_hill_bank cover props, wall pockets, and steep terrain while requiring the commander to remain visible.
Lens gap
Soldiers standing between the chase camera and the commander would fill the screen, so Renderer::compute_rpg_lens_gap lays a corridor along the ground from the eye to the commander, and LensGapExclusion::visibility dims bodies inside it. It returns a visibility from 0 to 1, not an in/out test. The old edge of the corridor is still where fading begins, so nothing that used to be fully visible thins out. A body reaches zero only in the inner core (35 % of the corridor's half-width) around the sight line. The corridor also fades over 0.4 m at the lens end and 0.6 m at the commander's end. A hard cut used to drop a soldier in one frame as the commander walked past, while he was still plainly in view.
Anything within 1.3 m of the lens, measured to the edge of its body, also dissolves, fully gone inside 0.45 m. A soldier beside the camera fills half the screen even though he is nowhere near the sight line. Mounted soldiers use a 0.95 m body radius in both tests, because a horse a metre off the sight line still covers it.
A squad member whose rendered position falls inside the commander is drawn at his shoulder instead. LensGapExclusion::focus_body_push moves any body within 0.62 m of the focus out radially to that distance, keeping the side it was on, and instance_prepare applies the push to the soldier's model before culling. It is presentation only: hits, contact points and the squad's slots are unchanged. In a wave of skeletons pressing the commander, several used to stand inside him, blades through his chest.
instance_prepare records the batch's request count before a soldier is appended. It multiplies the visibility into every request added after that count, which covers riders, mounts and attachments too. CreaturePipeline stamps it on each RiggedCreatureCmd. The character fragment shaders turn alpha into a screen-door dither (character_fade_discards) rather than blending. A fading body therefore stays opaque, writes depth and never shows its own insides. Soldier shadows come from the shadow pass, which the lens gap has never touched, so a body faded out of the corridor keeps its shadow on the ground.
Camera motion and framing
The camera seeds its FOV from the active framing state on first update, avoiding an entry zoom caused solely by initialization.
Idle breathing, bob, sway, strafe roll, sprint FOV, and impact impulse all obey accessibility motion controls. Breathing depends on commander stillness and is disabled with head motion. Bob, sway, and strafe roll use restrained amplitudes, and strafe roll follows the head-bob setting.
Anchor lag is radial rather than per axis, so diagonal travel does not produce a larger trailing distance than axis-aligned travel at the same speed. The normal sprint follow settles below the teleport clamp.
camera_motion_scale = 0 is a complete motion-free camera: no bob, breathing, roll, sprint FOV change, impulse, or entry settle. CommanderCameraRig.TheNeutralCameraIsCompletelyStill holds eye, FOV, and horizon to 1e-4 across idle and running samples.
Crowd framing lifts the lens over a melee. build_camera_inputs counts living soldiers within 3.5 m of the commander, from units whose centre is within 18 m. Nine bodies count as a full crowd. The rig eases a smoothed density in over about 0.6 s and, at full density, raises the eye 0.55 m, pulls it 0.45 m back and tucks the shoulder offset in by 55%. It lowers the look target by 30% of that height, so the frame stays on the commander and the camera looks over the helmets rather than straight down on them. At the first values (1.7 m lift, 0.9 m back, the full lift taken off the look target) a fight at the Sepulcher Bridge was framed almost from above, like the army view; at 1.3 m and 0.7 m it still read as a top-down view once the melee framing's own 1.34 m of height and the retraction rise were added, with the commander a small figure under the lens. Before this, a commander fighting a wave was framed from shoulder height behind a wall of shields and was not visible at all. Bow aim cancels the lift.
A lock-on or soft-focus target closer than 4.5 m (k_focus_min_reach) is pushed out to 4.5 m along its own bearing before the look point blends toward it. The look point blends 60% toward the locked enemy's chest and the melee look drop is subtracted after that, so a skeleton a pace in front of the commander pulled the look point down to his feet: the melee camera pitched about 30 degrees down (35 with the retraction rise) and the fight at the Sepulcher Bridge was filmed from above, mostly ground. The camera still turns toward a close target; it only stops diving at it.
Framing uses look_drop to keep the commander inside the viewport. The ordinary framings place the feet around normalized screen y 0.83–0.89 and the head around 0.51–0.59. The drop is applied after lock-on target blending so duel framing does not dilute it. Bow aim intentionally uses zero drop because the reticle is the camera axis.
LookingUpAndDownKeepsTheCommanderOnScreen covers the practical combat pitch band from -60° to +10°.
Lock-on
Lock-on reaches 20 m within 70° of the view and drops a target beyond 26 m. At the earlier 12 m, a formation charging the commander could not be locked until it was already in contact.
Lock-on selection and cycling are screen-space operations. Initial lock chooses the target nearest the view center. Cycling steps to the next target to the right and wraps from the rightmost target to the leftmost.
Facing authority is suppressed inside 0.75 m, tapers in from 0.75 m to 2 m, and is capped at 220°/s. Manual look suspends the lock spring for 0.35 s without dropping the target.
Person-scale building bodies are used for lock acquisition, melee line of sight, and bow aim.
The lock still writes m_view_yaw, which is also the player's stored free-look yaw. Separating lock framing yaw from persistent free-look yaw remains open work.
Combat input and buffering
One physical press requests one melee attack. Holding the primary button queues the next sword or spear attack at the authored recovery window and restarts after a short delay when idle. Bow stance uses the held button to draw and release.
CombatIntentQueueComponent uses an authored 0.15 s buffer and counts every outcome class: accepted, buffered, refused, expired, and overflow. expire_stale_intents records expiration, and push records overflow when the queue exceeds its supported pending capacity.
rpg_attack_buffer_window exercises a clean press, a press late enough to carry into the next action, and a press early enough to expire before the legal window.
Plain attacks play at their authored timeline speed. resolve_melee_intent starts from a swing_speed of 1.0 and clamps it to [0.85, 1.35]; look/swipe input may accelerate a light attack within that range. Heavy attacks cap at 1.0, so their weight comes from authored duration and stagger rather than an additional slowdown multiplier. The tired-swing penalty remains independent.
The render-side attack visual may outlive action_running by exit_blend_duration of 0.10 s; that interval is the blend into the base stance rather than part of the mechanical action.
Strike direction and commitment
Only an accepted held bow shot starts a draw. Power shots and evasive shots follow
their authored release events without entering the held-draw state. Guard, dodge,
stagger, weapon switching, and completed actions clear the transient bow state
through clear_bow_draw; stopped actions cannot advance a draw or drain stamina.
The overhold latch remains set until the player releases the button. Camera zoom,
movement restrictions, and both HUDs read this same state.
melee_interruption_at publishes phase-specific redirect_authority: full authority in windup, reduced authority in the early strike, and zero authority once the strike is committed. Body yaw follows view yaw only by that authored amount, so the player can aim the startup without rotating an active committed blade arbitrarily through the camera.
A committed action therefore owns its direction while still allowing explicit redirect windows.
A strike lunges toward its target: the authored step, plus whatever is needed to close the rest of the gap, spread over the strike's carry window. That extra is capped at 1.2 m (k_strike_max_gap_close). Uncapped, it closed the whole gap to the locked enemy, and the carry window is only a few ticks long, so a swing at a lock-on target eight metres away slid the commander seven metres in 0.12 s (about 100 m/s). At the Sepulcher Bridge that carried him from the spear line onto the bridge deck into the middle of a rising, where he was pinned against the parapet and killed. A target further away than the capped lunge is simply missed; the player walks or uses Vanguard Rush to close.
Contact, guard, and damage
hurt_body_contact_point() clamps the blade's closest point onto the target hurt body, represented as a capsule from 0.30 m to 1.70 m plus body radius. Damage, reaction, sparks, and guard resolution therefore share a contact point on the body that was actually hit.
Ordinary blocking uses the authored guard arc through frontal_arc_dot, vertical coverage, and guarded side. Perfect guard additionally requires the 0.60 m plate test during the authored perfect window.
game/systems/combat_actions/commander_defense_timeline.h is the source of truth for guard raise, perfect window, release, guard-break recovery, dodge startup, dodge i-frame interval, roll duration, and recovery. CommanderDefenseWindowsTest covers exact boundary behavior at 30, 60, and 120 Hz.
RpgHealthComponent counts resolved contacts by outcome: blocked, perfect, dodged, damaging, and guard-broken. Feedback is emitted from changes in those resolved counters rather than from the input request itself.
A blade swing that finds no soldier during its active window strikes the nearest enemy structure in reach: within the action's hit_shape.reach plus 0.35 m of the building's surface, and inside about 65° of the commander's facing. It deals the commander's attack damage once per swing through the ordinary deal_damage path, so structure multipliers, impact dust and building damage states apply exactly as they do for RTS melee. Previously the weapon trace refused buildings outright, and a commander standing at a gate could not dent it.
Duels
An AI commander who is not marching with his army steps away from any threat inside 15 m. That kept him alive against raids, but it also meant a player who walked up alone to fight him only ever saw his back. CommanderBehavior now accepts the challenge, issuing an AttackTarget on the challenger, when three things hold:
- the nearest threat is a commander under direct control, marked by
ContactSnapshot::player_driven; - no other enemy unit is inside that 15 m;
- the AI commander has at least half his health.
Otherwise he retreats as before. AI-against-AI play never sets player_driven, so it is unchanged.
The challenge is taken up from 25 m, not only from inside the 15 m danger radius. With no threat inside 15 m the commander walks back to his home post, so a player closing from twenty metres away watched him turn his back and leave; at Ticino Scipio could not be caught on foot. nearest_challenger_to looks for a player-driven commander within 25 m before the threat check, and the same one-on-one and half-health rules decide whether he fights.
Taking the duel also releases any lower-priority claim on the commander. GatherBehavior musters an idle commander at Low priority and re-claims him on every update, and claim_units lets a higher priority steal a unit only once its claim is older than the lock, measured from the last refresh. The duel claim therefore never succeeded: the AI accepted the challenge on every decision while the gather station walked Scipio away to his muster point. AGatherStationDoesNotHoldACommanderBackFromADuel covers this. The general starvation is fixed as well: UnitAssignment::claimed_since records when a behaviour first took the unit, and claim_units measures the steal lock from it, while assignment_time stays the last refresh that the stale-claim sweep reads. A low-priority claim re-made every update therefore holds its unit for the lock (2 s by default) and then yields to a higher priority (ARefreshedLowClaimYieldsToAHigherOneOnceItsLockHasRun).
Stamina
A light attack costs 12 stamina, a heavy 22, a dodge 18, and guarding 8 per second. Any spend pauses regeneration for 0.75 s through StaminaComponent::spend, so repeated attacks eventually produce a real refusal and recovery cycle.
In direct control the commander has at least 180 stamina and regenerates at least 30 per second (CommanderModeCoordinator), so a breath of about six seconds refills him. At 14 per second a full bar took thirteen seconds: in the Sepulcher Bridge waves the commander spent most of every fight with the WINDED flag up and his swings refused, which reads as a sluggish character rather than a cost to manage.
Arena commanders carry a stamina component and expose the current value in the combat trace. rpg_stamina_refusal_and_recovery drives expenditure to refusal, waits for recovery, and verifies a later attack can be accepted.
Dodge
The roll lasts 0.36 s and the i-frame window covers its first 0.20 s (k_commander_dodge_timeline), followed by 0.16 s of recovery. The commander travels 6.5 m/s along the dodge direction, about 2.3 m.
The i-frames hold whichever way the commander rolls. dodge_beats_contact used to accept a contact only when the roll led away from it (a dot product above 0.15). Inside a crowd every roll is toward somebody, so a roll away from one skeleton was still cut down by the next, and rolling through a swing, the standard answer to it, never worked. ARollThroughTheAttackerStillBeatsItsBlade covers this.
The presented roll is the crouched Evade reaction plus a full tumble of the model about the axis across the dodge direction (CommanderComponent::dodge_dir_x/z, mirrored into CreaturePresentationComponent). The tumble used to turn about the body's right axis, so a side or back roll was drawn as a forward somersault while the body slid sideways or backwards.
Crowd pressure
Attacker selection is deterministic through deterministic_unit_roll(entity_id, epoch) and uses an explicit fairness policy.
At most one active presser may come from outside the 100° visible arc. An incumbent presser receives a +2.4 selection penalty after three seconds in the slot, replacing its normal -0.55 incumbency preference and forcing the ring to rotate rather than pinning the same attackers indefinitely.
Squads surround a single body
A squad whose melee opponent is a single body (the commander or an enemy hero, but not an elephant or wildlife) arranges its soldiers in rings around that body instead of standing in its marching grid. The squad's root still chases to about a third of the contact distance, so before this every soldier stood in his grid slot around a root on top of the commander. Four skeleton squads attacking him were four grids stacked on one point: a ball of skulls, a dozen blades through his chest, and nothing a player could read as who strikes next.
formation_surround does it in two steps, both inside publish_formation_presentation:
SurroundBoardcollects every squad withink_surround_reach(7 m) of its single-body opponent, then splits the circle around each opponent into sectors. Each squad's sector is proportional to its live soldiers (a lone squad wraps at most 270 degrees) and starts centred on the side the squad came from. Overlapping sectors are pushed apart along the circle, so squads arriving from the same side fan out round him instead of piling up in front.plan_surroundfills that sector ring by ring. The first ring sits at the same contact distance the press-to-contact pull used before (both body radii plus 35 % of the weapon reach). Each further ring is one formation spacing out, and soldiers stand about 0.9 spacing apart along each ring. Soldiers nearest the opponent take the inner rings. Within a ring they keep the left-to-right order they already had around him, so nobody crosses the ring to reach his spot.
Elephants are left out: an elephant fights a squad by trampling 1.5 m into it (k_elephant_chase_penetration), and a ring that keeps its distance from the elephant would never let it arrive, so MixedMeleeEngagementTest saw squads and elephants stop trading blows. Wildlife is left out as well: a wolf darts in and bites whichever soldier is nearest, and soldiers re-forming around it kept changing who that was mid-bite.
Only the first ring presses in to contact. Outer rings face the opponent in the ready stance and wait. A soldier keeps his own engagement pair, so the sim still decides who deals damage. Ring spots are walked to with the normal crowd steering and terrain constraints, never the authored-position snap, because a ring around a commander on a bridge must not step off the deck into the river. Soldier positions are sim state (they are the spatial anchors for contact and commander hits), so this is deterministic and runs on the sim thread.
Combat feedback
A squad's ranged volley at a direct-control commander is one focused arrow. It carries 45% of the squad's damage (k_volley_share_on_hero), because the rest of the volley is not aimed at one man; a commander's own bow keeps full damage. Both release paths, the RTS volley and the authored bow action that skeleton archers fire through, take the damage from volley_damage_on_target; the first version of the rule sat only in the volley path, and the skeletons kept landing 26 a shot. At the Sepulcher Bridge two skeleton archer squads standing behind a melee did three quarters of a dead commander's damage, from a place he could not reach. The arrow is still aimed at where he stood when it was loosed, so moving gets him out of it.
Each health loss flashes the damage vignette at no less than 30% strength, rising with the size of the hit. The old curve scaled only with the fraction of health lost, and a 4200-health commander losing 80 per blow drew a vignette at about 3% opacity, so being hit had no visible feedback at all.
Player-controlled hit pause is presentation-local at 0.045 s and never stops the mechanical action timeline. Other units may retain their stronger per-entity presentation pause.
A commander hit never changes the global simulation time scale. Unit updates, projectiles, mission timers, and the rest of the match continue at normal simulation time.
Soldier trace samples expose is_swing_recoiling and hit_reaction_tilt_degrees. A victim already committed to a swing can show an authored swing recoil rather than having the action cut in half, and HitReactionObserved accepts that recoil when it carries real visible tilt.
Sword presentation
Sword locomotion and idle stances author both hand placement and grip orientation. HumanoidHeldPoseSample carries blade_direction and offhand_axis, and clips whose ground stance orients those grips rebuild the humanoid attachment frames during baking.
carry_sword_and_shield keeps the upright blade in front of and clear of the torso. Spear, caster, and stave profiles retain their own socket orientation paths.
Authored sword attack keys lift a moving foot according to horizontal foot speed along the same Hermite path used for translation. This lets a travelling foot leave the floor instead of skating across it. AuthoredSwordStepsLeaveTheGroundInsteadOfSkating covers all five RPG sword clips.
Action-to-action combo links preserve the outgoing clip at its cut phase and blend it under the incoming clip for 0.14 s. The link is keyed on the resolved clip and is read from incoming inputs before selection, so the fade begins on the same frame as the transition.
Idle-to-action and action-to-idle arm seams still move roughly 0.19–0.21 m in one frame. Those transitions belong to the base-stance/action blend policy and remain separate work.
Weapon in hand
The commander's clips follow the weapon he is holding. An archer commander holds his bow in both stances: in bow stance every shot plays the draw-and-release, and in blade stance his combo strikes with the bow (slash left sweeps it across, slash right chops it down, the thrust rams it forward two-handed). Under RTS control a melee exchange plays the same bow strikes. Until this, every combo step played the same bow shove, and a later attempt that put a sword in his hand was reverted: he carries a bow, so he fights with it. See "Clips follow the weapon in hand" in docs/ANIMATION_ARCHITECTURE.md.
Ability and hit presentation
The commander Aura reads as a light touch on the field, not a selection overlay. Its boundary is a thin ring (0.12 m, alpha about 0.24) and every buffed soldier carries a small, faint halo (0.42-0.56 m, 0.035 m thick, alpha about 0.18). At the first values (a 0.22 m boundary at 0.62 alpha and a 0.8-1.15 m ring at up to 0.9 alpha under every soldier) an Aura at the Sepulcher Bridge painted the spear line in overlapping bright yellow circles and laid a yellow line across the ridge, which looked like a debug view. The warm glow over the aura's field is drawn at intensity 0.14; at 1.0 it lit the whole 12 m circle as an additive orange light, and at dusk on the Sepulcher Bridge it washed the deck and everyone on it out to flat yellow. The expanding pulse when the Aura starts is unchanged.
Floating numbers are rounded badges filled with the colour of what happened, so the kind reads before the digits do: red for health a player's soldier lost (deep crimson with a skull for a death), orange for damage dealt (amber with a skull for a kill), green for a heal, and for the economy the resource's own colour (gold yellow, food orange, wood brown, stone grey, iron steel blue, population violet; a spend is brick red). Each badge has a light top-to-dark gradient, a pale rim, a gloss strip and a drop shadow, white 21-29 px digits with a dark outline, pops in at 0.55x, overshoots to 1.18x and settles, and rises 46-90 px over 1.15 s (1.35 s for a death). The commander's own hit is a larger badge (30-42 px digits) in the same style, orange to red by severity and gold for a kill, with its ring flash and sparks, held fully opaque for two thirds of its 1.05 s. In commander view the same badges show for every hit within 30 m of the commander (his men's wounds in red, their blows in orange) and the economy badges stay on, so the battle around him keeps its colour; only fights farther off are dropped. An attempt to quiet the commander's burst by dropping its plate and shrinking it to 22-32 px made every number on the field hard to see, and the badge colours are what tell a food delivery from a heal from a wound at a glance, so the numbers stay big and coloured.
Replays of direct control
A recorded match replays direct control exactly (--replay R --replay-verify passes). Per-tick input (buttons, view yaw and view pitch) is in the commander input frames. Everything else the player does to the commander from the GUI thread between ticks is a commander action line ({"commander_action": ..., "tick": T}): entering and leaving direct control, cycling the lock-on target (with the view it was cycled from), the close-camera and weapon toggles, and releasing the commander for a rally. During playback CommanderViewModel::apply_replayed_actions applies each action at the start of tick T + 1, which is where it fell between ticks when it was recorded, and enter and exit are idempotent so an exit the simulation already made (the commander fell) is not repeated. While a replay drives the match, the viewer's own commander keys, mouse and toggles are ignored, and the verdict does not leave direct control on its own: the recorded exit does. The zero-length update that settles the camera on entry neither records nor consumes an input frame, so the first frame of control is the same live and replayed. Each input frame also carries the bow's aim ray (aim: the camera eye and forward the tick used). The bow fires along the camera axis so the reticle is honest, but the camera rig is presentation state advanced per rendered frame, so the same match rendered at a different frame rate aims a different ray; a replay of a bow fight diverged at its first volley until playback took the recorded ray.
Before this only the input frames were recorded, so playback never entered direct control: the commander stood still under the AI, and a match played in the commander view diverged about half a second after the player took control.
Accessibility
Game::Accessibility::CommanderInput contains look sensitivity X/Y, invert Y, camera impulse, head bob, FOV scale, and hold-versus-toggle guard. UiPreferences persists and publishes all seven.
The camera rig applies the individual motion switches in addition to camera_motion_scale. Run and dodge FOV effects scale with reduced-motion settings. CommanderAccessibilityTest verifies behavior changes, not merely stored preference values.
Performance
The first 0.75 s of every scenario is a prewarm window. Those frames are reported separately as prewarm_frames and prewarm_max_ms and are excluded from gameplay p50/p95/p99/max budgets.
scripts/rpg_gate_report.py evaluates the reference performance contract on every run:
- p95 ≤ 16.67 ms
- p99 ≤ 20.0 ms
- post-prewarm max ≤ 33.3 ms
- GPU timing present
- prewarm window marked
The values are always reported and become gate failures only under --enforce-performance.
Five scoped accumulators measure motor, targeting, weapon trace, engagement, and camera once per tick and publish them as costs in the commander trace. rpg_skirmish_three_attackers measures the full RPG slice at roughly 0.019 ms against a roughly 0.19 ms simulation p95 on the recorded reference run; those numbers are diagnostic and must be interpreted with the hardware metadata in run_config.json.
Per-frame metric budgets scale with frame duration. The Arena --fps option still changes the batch loop's fixed simulation step rather than only presentation sampling, so strict cross-rate equivalence remains incomplete until render sampling is decoupled from simulation stepping.
Lifecycle
CommanderLifecycleSoakTest covers repeated mode entry/exit, input accounting, long combat, and pause/resume cleanup.
The suite performs one hundred enter/exit cycles and requires every commander flag and input state to restore correctly. It runs a ten-minute headless duel at 60 Hz with every input edge accounted for and no buffer overflow. It also injects pause at forty points inside an action and requires no action to remain running two seconds later.
UI and integration
Direct-control UI paths follow the same ownership rules as the gameplay systems.
Main.qml::return_to_main_menu() restores focus whenever a sub-screen returns to the main menu, preserving keyboard navigation.
OrdersViewModel::refresh_context_intent clears RTS cursor intent while commander mode is active, preventing ground/order hints from leaking into the crosshair state.
Direct control has no bottom bar. The pointer is captured, so the commander bar's buttons cannot be reached in combat and its readouts repeat the combat overlay; HUD.qml collapses the bottom panel to zero height while commander_rpg_mode is set, and it returns only while a rally destination is chosen with a free cursor. Metrics.commanderBottomBarMinHeight sizes that placement bar, below the RTS bar height, and is covered by design-token/QML layout tests. The chase framing therefore uses the whole viewport, and the commander's feet are no longer hidden behind a panel.
RpgFpvOverlay.qml owns everything direct control shows:
- The ability row carries Special, Rush, Wind, Aura (only when the commander has one) and Rally. In direct control Rally plants the flag where the commander stands (
rally_where_the_commander_stands); it no longer frees the cursor and opens the placement mode, which in a melee left him standing still under the wave while the player hunted for a spot. Placing the flag elsewhere is still the army view's rally. Keycaps readInputBindings.display_shortcut_for, so a rebound key is never misreported. - The weapon chip names the current weapon and its swap key.
- The combo pips sit under the reticle.
- A short toast confirms camera-mode and weapon changes.
- The stamina bar flashes "WINDED" when
last_input_outcomereportsInsufficientStamina. - A controls strip, built from the live bindings, fades in on entry and out after a few seconds.
sync_attack_range_rings publishes no RTS reach rings while direct control is active, and FocusTracker drops the army's target-focus markers: the crossed-swords locked-target ring and the red incoming-attacker ring. The commander stays selected, so every squad of a wave attacking him drew a red ring under itself, and a melee at the bridge was a field of red circles. The renderer also draws no selection or hover rings in the RPG world render mode (Renderer::selection_markers_visible): the selected commander had a gold ring under his own feet, and the hover ring followed whatever stood under the captured crosshair. The controlled commander stays selected, and a bow stance's ring otherwise lies across the chase view as a line on the horizon.
Commander dialogue uses the compact card in direct control: a small portrait, a column a fifth of the screen wide, and no faction row or "Click to dismiss" line, since a captured pointer cannot click it. The full card covered a third of the fight.
When the match reaches a verdict, GameEngine leaves commander mode on the GUI thread. Otherwise the captured, recentred pointer could never reach the verdict's buttons.
The autosave and save-progress card moves to the top centre in direct control, clear of the vitals and ability plates.
When the game is paused, the pause binding may resume from any input context even though Space is also a commander dodge binding. The pause overlay names the actual bound key.
Scenario/template prewarm includes every nation in NationRegistry::player_nation_assignments(), including forces that have no spawned unit yet.
PlayerDefeatWatcher receives MissionWaveRuntime::owner_has_unspawned_waves as a still_expected predicate, so a wave owner is not declared defeated between scheduled waves.
Notifications pass glyphs to IronNotification.icon, and commander faction labels use Design.FactionTheme.nameFor.
Mission speech for hold_the_sallow_ford and the_timber_levy uses the field commander controlled by the player.
The following integration items remain open: moving the pointer to a top-bar control can continue feeding mouse-look, and the empty production panel says "No Barracks" when it means no barracks is selected.
Working rules
- Every behavioral defect gets a deterministic regression or Arena expectation that reproduces it as a time series whenever the system allows deterministic reproduction.
- Every behavior-changing change includes trace or rendered evidence that demonstrates the resulting contract.
- Thresholds change only for an explicit design reason.
- Camera collision, combat balance, animation, and effects remain narrow work packages rather than being bundled into unrelated changes.
- Deterministic simulation and replay behavior take priority over presentation convenience. Presentation interpolation never feeds authoritative combat or navigation queries.
- ASan/UBSan and the complete normal test suite are part of milestone closure.
- The live scenario manifest, not a prose snapshot in this document, defines current green/red gate status.
Current known limitations
- Lock-on framing still writes the stored free-look yaw instead of using a separate camera-owned framing yaw.
- Locomotion has four authored lateral clips rather than a complete eight-way set, and slip during locomotion does not yet have a dedicated gate metric.
- Idle-to-action and action-to-idle sword seams still have a visible arm-position discontinuity owned by the base/action blend.
- Arena
--fpschanges simulation timestep, so it cannot yet prove identical behavior under different presentation sampling rates. - The barracks RTS navigation footprint is smaller than its rendered geometry; the RPG person-scale body does not change RTS pathing.
- Two HUD/integration issues remain: mouse-look over top-bar controls and the empty-production wording.
- Climbing a stair or ladder plays the ordinary walk cycle; there are no climb clips.
Related
docs/CAMERA_CONTROLS.md— RTS camera behavior shared with direct control.docs/COMBAT_SYSTEM.md— combat systems driven by the RPG slice.PATHFINDING_ARCHITECTURE.md— shared movement and direct-control body rules.tools/arena/README.md— Arena harness, scenario schema, traces, and artifacts.