The RTS camera supports nine ways to move or restore the view. The same control definitions feed the in-battle legend and field manual, while automated tests cover the underlying geometry and input rules. This page explains the complete control model and ends with the manual regression pass needed for layouts and interactions that unit tests cannot fully reproduce.
| Control | How | Implementation |
| Edge scroll | Push the cursor into a screen edge | ui/qml/Main.qml, edge_scroll_overlay |
| Keyboard pan | Arrow keys or WASD; Shift for a double step |
rts.camera_pan_* in ui/input_bindings.cpp |
| Drag pan | Hold the right mouse button and drag | ui/qml/GameView.qml, renderArea mouse area |
| Zoom | Mouse wheel, or PgUp / PgDown |
rts.camera_zoom_* |
| Rotate | Q / E; Shift for a larger step |
rts.camera_rotate_* |
| Tilt | Ctrl+Up / Ctrl+Down; Shift for a larger step |
rts.camera_tilt_* |
| Minimap jump | Left-click or drag the minimap | ui/qml/HUDTop.qml, minimapMouse |
| Follow | Button in the top bar | ui/qml/HUDTop.qml |
| Reset | Home, or the Reset button in the top bar |
rts.camera_reset |
ui/qml/CameraGuide.qml is the single descriptive list used by every help surface: the compact in-battle legend in CameraLegend.qml, the Camera tab in HelpPanel.qml, and the live edge-scroll status shown by both.
Adding or renaming a camera control should therefore start in CameraGuide.qml rather than by duplicating text in several interfaces.
The three basic camera motions should remain distinct in both code and player-facing language:
Camera::yaw and CameraService::yaw.CameraService::tilt.Camera::orbit(yaw, pitch) remains the lower-level two-axis primitive used by both rotation and tilt.
Tilt is bound to Ctrl+Up and Ctrl+Down. R is reserved for the commander rally action, avoiding a contextual collision between camera motion and rally placement.
CameraService::tilt accepts a direction where positive means raise the camera, but it passes the opposite sign to the lower-level orbit function.
That inversion is intentional. The camera's pitch describes the elevation of the view direction: a near-overhead view is around -85, while a near-level view is around -5. Raising the camera therefore moves the numerical pitch downward.
Tests assert the camera's height above its target rather than reading pitch directly, which protects the intended user-facing direction from sign confusion.
InputBindings stores a primary and alternate chord for each action through InputBindings::Slot. This is how keyboard panning can support both the arrow keys and WASD as first-class bindings.
Alternate bindings are persisted under the action ID with an |alt suffix, rebound from their own button in Settings → Controls, and resolved by actions_for_key alongside the primary chord.
Using WASD for camera pan required moving conflicting RTS commands: Attack uses C and Stop uses Z. Within one context, a key must have one unambiguous meaning.
Saved keymaps survive action renames. Migration maps stored chords according to behavior, not merely old labels. For example, the former rts.camera_orbit_left action actually lowered the camera, so its binding migrates to rts.camera_tilt_down. Preserving the physical key while reversing the action would be worse than dropping the binding.
Every map authors a camera view appropriate to its scale. Small maps and large battlefields therefore need different reset distances.
Game::reset_framing in game/camera_framing.h derives reset framing from the map's authored camera:
Authored tilt and yaw are preserved.
GameConfig::camera_reset_framing applies this rule and falls back to the built-in default only when no map is loaded.
The same framing function is consumed by:
app/session/level_orchestrator.cpp;CameraService::snap_to_entity; andSkirmishRuntimeCoordinator::center_camera_on_local_forces.Sharing one calculation prevents opening framing and reset framing from drifting apart.
Keyboard pan, wheel zoom, and Q / E rotation each have a user-adjustable speed scale from one quarter to three times the designed pace.
Values persist through App::Core::UserSettings and are propagated to the atomics in game/render_bridge/camera_speeds.h. CameraService::move, CameraService::zoom, and CameraService::yaw read those values on every call.
Drag pan, minimap jumps, and tilt remain unscaled because their displacement is already determined by the gesture itself. Applying another multiplier would make the same physical motion encode two layers of speed.
The edge-scroll calculation lives in ui/edge_scroll.cpp so it can be tested independently by tests/ui/edge_scroll_test.cpp.
Its important rules are:
k_entry_push (35%) as soon as the cursor enters the band;sqrt(2) speed increase; and-1), zero-sized surfaces, or coordinates outside the surface produce no movement.Scaling by interface size keeps the target usable on high-resolution displays. A 26-logical-pixel band should not collapse into a tiny physical sliver while every other UI target doubles at 200% scale.
Horizontal and vertical edges deliberately use the same geometry and response curve so top and bottom scrolling feel as strong as left and right scrolling.
EdgeScroll.cursorIn(item) reads QCursor::pos() and maps it into the edge-scroll overlay. A 16 ms timer in the overlay polls that position.
The system intentionally does not depend on the overlay's own hover events.
The full-screen edge overlay sits below the HUD (z: 0.5 versus the HUD's z: 1) so HUD controls retain hover states and tooltips. In Qt, hover delivery stops at the first item that accepts the event. An overlay above the HUD would swallow HUD interaction; an overlay below it would fail to receive pointer movement whenever a HUD element was under the cursor.
Polling the platform cursor solves both sides of the problem: HUD controls receive their own events, while edge scrolling can still see every physical screen edge.
The minimap is itself a camera-control surface, so edge scrolling must not compete with it.
Suppression is based on state rather than a hand-tuned geometric margin:
HUD.blocks_edge_scroll() returns true for the minimap rectangle; andmainWindow.edge_scroll_disabled remains true during a minimap drag through hud.minimap_drag_active, even if the pointer leaves the minimap while dragging.This lets the edge band be sized for usability rather than constrained by a fragile one-pixel clearance around the minimap.
mainWindow.edge_scroll_disabled is a derived property:
readonly property bool edge_scroll_disabled: gameViewItem.camera_pan_active
|| !mainWindow.active || mainWindow.overlay_active
|| hud.commander_rpg_mode || hud.minimap_drag_active
camera_pan_active is also derived from live input state:
renderArea.key_pan_count > 0 || renderArea.mouse_pan_active
Keeping these values derived prevents them from becoming latched when input is interrupted. For example, a modal opening or the application losing focus during a right-drag must not leave edge scrolling disabled for the rest of the session.
Polling the platform cursor means the overlay no longer learns suppression indirectly by losing hover events. Every state that should block edge scrolling therefore needs to be represented explicitly in the derived condition.
The overlay timer is likewise controlled by a live condition rather than started only by an enter event. If a panel closes while the cursor is already resting at an edge, scrolling should resume without requiring the pointer to leave and re-enter.
Two similar-sounding rules serve different purposes and should not be merged.
in_hud_zone() blocks world hoveredge_scroll_overlay.in_hud_zone() decides where world hover stops. It reads hud.top_panel_height and hud.bottom_panel_height, using the exact snake-case property names.
Those full-width zones prevent world units and placement previews from responding to a pointer that is actually interacting with the HUD.
blocks_edge_scroll() blocks camera scrollingHUD.blocks_edge_scroll() is much narrower and covers only the minimap.
Camera edge scrolling intentionally continues behind the top and bottom HUD panels so every physical screen edge remains useful. Folding the world-pointer and camera-scroll zones together would make the top and bottom edges dead.
The overlay accepts Qt.NoButton and sits beneath the HUD, so it does not consume clicks intended for HUD controls.
Automated tests cover geometry, binding behavior, and legend contents, but several interactions depend on real windowing, focus, scale, and pointer behavior. Run this checklist when changing camera controls or edge scrolling.
off when disabled.Esc mid-drag, return to the battle, and confirm edge scrolling still works.WASD, and rebinding either slot updates the legend.Backspace leaves the primary binding intact; Default restores both.WASD pans; A and S no longer mean Stop or Attack, while C and Z do.Ctrl+Up and Ctrl+Down tilt; plain Up and Down continue to pan when Ctrl is not held.R places a rally flag without moving the camera.Home and the top-bar Reset button land on the same view.The camera system is easiest to maintain when every movement has one name, every help surface reads one source of truth, and edge scrolling is controlled by explicit geometry and state rather than incidental event delivery.