Camera & Input
Status: In Design
Author: user + agents
Last Updated: 2026-09-21
Implements Pillar: Foundation — serves all three pillars as the lens the player sees them through
Overview
Camera & Input is the settlement's viewing and control layer: a rotatable, zoomable three-quarter strategic camera and the input action maps that drive it and everything the player does through it. It is the foundation-layer system nothing else can function without — building placement, unit selection, and every UI overlay all read from the camera's current transform and consume input through the same action maps this system owns. It exists because every other system needs a single, authoritative answer to two questions: "where is the player looking?" and "what did the player just do?"
Player Fantasy
creative-directornot consulted — Lean mode. Review manually before production.
The camera is where the player feels like a steward surveying a frontier they command, not a spectator watching one. Rotating and zooming is a small, constant act of choosing what to look at and trust — the player decides whether to inspect the smithy's queue or sweep the horizon for the treeline going cold. That choice is the fantasy: the settlement's strength is legible in its stockpiles only if the camera lets the player go check.
Indirectly, this system carries the entire weight of Pillar 3's promise — "threat is told by light and colour temperature, never by UI alone" — because the camera is the only thing standing between that promise and the player. A beautifully lit cold treeline means nothing if the camera never shows it, or shows it too small to register, or lets the player rotate away from the one direction that matters. The camera doesn't need to feel dramatic on its own; it needs to never get in the way of the drama the world is already telling.
Detailed Design
Core Rules
Movement
- The camera pans across the ground plane via: (a) keyboard directional input (WASD / arrows), (b) edge-scroll when the cursor is within a configurable screen-edge margin (default 12px) while the window has focus, and (c) middle-mouse-button drag, which takes priority over edge-scroll while held.
- Pan speed is screen-space relative, not world-space fixed: the camera covers the same visual distance per second at any zoom level, achieved by scaling world-space pan speed proportionally to zoom distance.
- Panning is clamped to a world-space bounding box slightly larger than the playable map. The clamp is soft — velocity dampens over the last 10% of range rather than stopping dead.
- Edge-scroll is disabled while any modal UI has focus (build menu, dialog) and while a drag-select box is being drawn.
Rotation
- The camera rotates freely around the vertical (yaw) axis via right-mouse drag, or
Q/Efor fixed-increment rotation. - Free-drag rotation has no snapping during the drag — full 360° freedom, because Pillar 2 depends on the player composing their own view of a settlement they designed.
- On release of a free-drag rotation, the camera does not auto-snap to any angle. Snapping applies only to
Q/E, which steps in fixed 45° increments with a short eased transition (~150ms). Two paths: free composition, or low-effort predictable framing. Neither is forced. - Pitch is fixed within a narrow band (35°–55° from horizontal) and is zoom-linked, not independently player-controlled: pitch flattens as the camera zooms out, preserving the "readable at strategic zoom" rule rather than looking down a steep well at the whole valley.
- Keyboard rotation speed and mouse-drag sensitivity are both tuning knobs.
Zoom
- Zoom is continuous between a near clamp (close enough to read individual settler roles and gear silhouettes, per the Visual Identity Anchor's "Silhouette first" principle) and a far clamp (far enough to see the whole playable valley plus a threat-relevant buffer beyond the palisade).
- Zoom input is scroll-wheel, applied as eased interpolation toward a target distance — never an instant snap.
- Zoom is cursor-anchored: the world point under the cursor when zoom begins stays under the cursor as zoom changes. This is a usability non-negotiable, not a feel knob.
- At either clamp, further input in that direction is rejected outright — no rubber-banding, no error state, no blocking of other input.
- Zoom does not change pan's screen-space feel (rule 2) but does change what edge-scroll and click-to-select can reach. This is a deliberate design lever, not an edge case.
Exposing world-space direction under arbitrary yaw
This is the contract that closes the directional-threat problem flagged as
unresolved in the 2026-09-14 UX review. That review scoped it as a Threat
Forecasting UI bug; it is actually a coordinate-system contract problem,
and Camera & Input is the only system with an authoritative, frame-accurate
answer to "what is the yaw right now."
- Camera & Input exposes a per-frame compass conversion: given a world-space direction (e.g. "the raid approaches from world bearing 47°"), it returns the screen-space bearing relative to live camera yaw (
screenBearing = worldBearing − cameraYaw, normalised 0–360°). It is a pure read; this system does not know or care what consumes it. - The conversion is recomputed every frame the yaw changes — never cached, never event-driven off rotation-end. An indicator that only updated when rotation stopped would visibly lag during a drag. Cost is one subtraction, so there is no performance argument for doing otherwise. **The UX review's failure mode — "the player rotates away and the indicator becomes wrong" — is impossible under this contract, because no screen position is ever stored.**
- Raw camera yaw is also exposed as a first-class queryable value, updated the same frame any rotation input is applied, for systems needing the transform rather than a pre-converted bearing.
- No soft camera constraints near threat, ever. The camera is never nudged, resisted, or auto-rotated toward or away from a threat direction. This deliberately forecloses "constrain rotation near danger" as a solution: it would violate Pillar 2 and would fight the player's intent during exactly the moment — an incoming raid — when they most need to freely inspect their own defences. Indicator correctness solves the yaw problem; camera freedom is preserved absolutely. (Decision confirmed by the user, 2026-09-21.)
- No minimap or compass ring is owned by this system. Camera & Input provides data only (rules 15–17). Whether the presentation is an edge glyph, a compass ring, or a minimap is a Threat Forecasting UI decision made downstream of this contract.
Gamepad scope — Partial, and what that means
- Gamepad support is Partial (
technical-preferences.md): the camera is fully drivable by gamepad, but two mouse verbs have no gamepad equivalent and are mouse-only by design, not by omission. - Mapped: left stick pans, at the same zoom-scaled speed as every other pan input (Formula 1);
LB/RBrotate in fixed 45° increments, reusing the Q/E path androtationDurationexactly (Formula 5b); triggers zoom, eased by Formula 5a identically to scroll. - Mouse-only: edge-scroll (Rule 1b) has no cursor to sit near, and free-drag rotation (Rule 5) has no drag. Their gamepad equivalents are stick pan and
LB/RBrespectively — the capability is covered, the input shape is not replicated. Rule 6's no-snapping guarantee therefore does not apply on gamepad, where all rotation is 45°-stepped; this is the one place gamepad is strictly less expressive than mouse, and it is accepted rather than worked around. - Cursor-anchored zoom (Rule 12) falls back to a screen-centre anchor on gamepad. Rule 12 calls the anchor a usability non-negotiable; with no cursor, the world point under screen centre is the honest substitute. Formula 4 is unchanged — only
cursorWorldPoint's source differs (screen-centre raycast rather than cursor raycast). The player aims by panning, which is what a stick is for. - What Partial explicitly does not promise: gamepad-navigable menus, unit selection, or building placement. Those belong to the systems that own them. This rule covers the camera only, and the action maps it publishes must not be read as a commitment on their behalf.
*Rationale: this is the foundation-layer input system and every downstream system inherits its action maps. Leaving gamepad undeclared until Pathfinding and Building Placement are built against a mouse-only contract is the expensive version of this decision. The platform note in technical-preferences.md asks to "avoid hover-only interactions where a gamepad fallback would be cheap to add later" — cursor-anchored zoom is exactly that interaction, and the screen-centre fallback is exactly that cheap fallback.*
States and Transitions
| State | Description | Entry Condition | Exit Condition | Input Available |
|---|---|---|---|---|
| Free Look | Default. Full pan/rotate/zoom per Core Rules. | Game start; return from any other state. | Entering another state per its trigger. | All camera controls; all gameplay input passes through normally. |
| Placement Preview | Pan/zoom/rotate remain fully active — the player must be able to look around while placing. A ghost building follows the cursor. | Player opens Building Placement. | Placement confirmed, cancelled, or Esc. | Pan/rotate/zoom unchanged; left-click is captured by Placement, not unit selection. |
| Drag-Select | A marquee box is being drawn to select multiple settlers. | Left-click-drag on open ground in Free Look. | Mouse button released. | Keyboard pan, MMB-drag and RMB rotate all work; edge-scroll suppressed (Core Rule 4) so the box is not disrupted by unintended panning. |
| Muster Focus (nudge, not a lock) | A one-time eased recentre toward the defensive perimeter, at a zoom showing the palisade and the threat direction. | Muster bell rung. | Any player input, or the nudge completes with no input and settles into Free Look. | All camera controls remain live throughout. This is a recommendation, not a mode. |
| Locked/Cutscene (reserved — unused in MVP) | Player camera control suspended; camera follows a scripted path. | Not triggered anywhere in MVP scope. | N/A for MVP. | None. Reserved for a future narrative or defeat/victory beat. |
Transitions
| From | To | Trigger |
|---|---|---|
| Free Look | Placement Preview | Open build menu, select a building |
| Placement Preview | Free Look | Confirm placement / cancel / Esc |
| Free Look | Drag-Select | Left-drag begins on open ground |
| Drag-Select | Free Look | Mouse button released |
| Free Look | Muster Focus | Muster bell rung |
| Muster Focus | Free Look | Any player camera input, or nudge completes |
Design rationale. Only Locked/Cutscene actually removes player agency, and it is explicitly unused in MVP — there is no scripted sequence anywhere in the MVP feature list. This is deliberate: Pillar 2 makes camera freedom close to sacred, so the state machine is written to make "everything stays free" the obvious default rather than something accidentally eroded by a well-meaning feature.
The Muster Focus row exists specifically to preempt a plausible implementer mistake — "the muster should snap the camera to the danger" — with an explicit ruling: it nudges and gets out of the way. Any input cancels it immediately. (Decision confirmed by the user, 2026-09-21.)
Interactions with Other Systems
The pattern throughout: Camera & Input publishes stateless utilities; consumers call them on their own update cycle. Nothing writes back to camera state. Camera & Input never needs to know its consumers exist.
Pathfinding & Movement
- Exposed:
ScreenPointToWorldRay(screenPos) -> Rayfor click/hover target resolution; view frustum for any culling/LOD decisions. - Format: a single utility call — a pure function of current camera transform plus screen position.
- Ownership: Camera & Input owns and publishes the utility. Pathfinding never reads the camera transform directly, so if the projection changes (orthographic vs. perspective tuning) only one system needs updating.
- Direction: one-way, Camera & Input → Pathfinding. Movement never writes back to camera state.
Building Placement & Construction
- Exposed: the same raycast utility for ghost-building cursor tracking against terrain, plus a mode handshake — Building Placement calls
EnterPlacementPreview()/ExitPlacementPreview(), and Camera & Input suppresses unit-selection input routing while keeping pan/zoom/rotate live. - Format: utility call for raycasting; a simple state enter/exit signal for the mode handshake.
- Ownership: Camera & Input owns the raycast utility and the state machine. Building Placement owns ghost-building logic and footprint validation — Camera & Input has no opinion on placement validity.
- Direction: bidirectional but narrow. Placement → Camera (mode signal only); Camera → Placement (raycast results every frame the ghost moves).
Threat Forecasting UI
This is the load-bearing interaction and the one the 2026-09-14 UX review flagged as unresolved.
- Exposed:
GetCameraYaw() -> float— current yaw in world degrees, updated the same frame any rotation input is applied.WorldBearingToScreenBearing(worldBearing) -> float— the Core Rule 15 conversion, normalised 0–360°.GetViewFrustumWorldBounds() -> Bounds— so the UI can tell whether a threat's world position is already on-screen, and therefore whether an edge indicator is needed at all.- Format: pure, stateless queries. No events, no polling loop owned by Camera & Input on the UI's behalf. Threat Forecasting UI calls these on its own render cycle.
- Ownership: Camera & Input owns and publishes the three queries. Threat Forecasting UI owns everything the player actually sees.
- Direction: strictly one-way. Threat Forecasting UI never writes to camera state — it must not, per Core Rule 18.
- Why this closes the flagged bug: the failure mode was "the player rotates away and the indicator becomes wrong." That can only happen if a screen position is cached or computed from a stale yaw. By contract the conversion is live and stores nothing, so there is no code path where it can go stale. The bug is closed at the interface, not by a policy implementers must remember.
Boundary: what belongs here vs. in Threat Forecasting UI
| Camera & Input (this GDD) | Threat Forecasting UI (its own GDD) |
|---|---|
| Yaw query and world→screen bearing conversion, with the frame-accuracy guarantee (Rules 15–17) | Visual design of the indicator: edge glyph vs. compass ring vs. off-screen arrow, and how it animates |
| The promise that threat state never constrains rotation (Rule 18) — forecloses "soft-lock the camera" before the UI designer starts | Colourblind encoding: luminance and fog density as co-signals alongside colour temperature (per the UX review) |
| Frustum bounds — camera geometry, not a UI fact | How indicator prominence scales with threat proximity or escalation tier |
| Multiple simultaneous threats: stacking, prioritisation |
Why the split lands here: coordinate transforms belong with the system that owns the coordinate frame. Camera & Input is the only system with an authoritative, frame-accurate answer to "what is yaw right now." If Threat Forecasting UI re-derived or independently tracked yaw to do this conversion itself, that would be two places computing "current camera angle" — a duplicated source of truth that can desync. Publishing one utility here removes that risk structurally rather than by convention.
Formulas
Two defaults —
nearClampandfarClamp— cannot be validated until settler
models and the terrain mesh exist. They are carried into Open Questions, not
treated as settled. Every other judgement call is marked inline.
1. Zoom-scaled pan speed (Rule 2)
panSpeed = basePanSpeed × (zoomDistance / referenceZoomDistance)
| Variable | Type | Range | Description |
|---|---|---|---|
panSpeed | float | 7.5–37.5 units/s | World-space camera translation speed this frame |
basePanSpeed | float (tuning) | 15 units/s default | Pan speed at the reference zoom distance |
zoomDistance | float | 12–60 | Camera-to-focal-point distance now |
referenceZoomDistance | float (tuning) | 24 default | Zoom distance at which basePanSpeed was tuned by feel |
Output range: 7.5 units/s at near clamp to 37.5 at far clamp. Linear, and implicitly bounded because zoomDistance is clamped by Rule 10 — no further clamping needed.
Example: at zoomDistance = 48, panSpeed = 15 × (48/24) = 30 units/s. At zoomDistance = 12, panSpeed = 7.5. A settler crosses the same fraction of the screen per second at either zoom.
Applies uniformly to keyboard pan, edge-scroll and MMB-drag — one shared function, per the stateless-utility principle in Interactions.
*Judgement call: basePanSpeed and referenceZoomDistance are placeholders needing in-engine tuning against actual valley scale.*
2. Soft-clamp damping near map edge (Rule 3)
edgeDampFactor = 1.0 while distanceFromCenter ≤ 0.9 × boundsHalfExtent, otherwise edgeDampFactor = smoothstep(1.0, 0.0, (distanceFromCenter − 0.9 × boundsHalfExtent) / (0.1 × boundsHalfExtent))
where smoothstep(a,b,t) = a + (b−a) × t²(3−2t), t clamped to [0,1].
| Variable | Type | Range | Description |
|---|---|---|---|
edgeDampFactor | float | 0.0–1.0 | Multiplier on pan speed and residual velocity |
distanceFromCenter | float | 0–boundsHalfExtent | Focal point distance from bounds centre, along the axis of motion |
boundsHalfExtent | float (tuning, per-axis) | map-dependent | Half the width/depth of the soft-clamp box |
Output range: 0.0–1.0, bounded by construction. Velocity reaches exactly zero at the boundary — that is what makes this soft rather than a wall.
Example: bounds half-extent 200, damping band starts at 180. At distanceFromCenter = 190: t = 0.5, smoothstep(1,0,0.5) = 0.5. Pan speed is halved at the band's midpoint.
*Judgement call: smoothstep over linear. A linear ramp has a visible slope discontinuity at the 90% seam; smoothstep has zero derivative at both ends, so entering the damping band is imperceptible rather than feeling like the camera suddenly got heavy. Measured per-axis, not radially, because the valley is closer to a rectangle than a circle.*
3. Zoom-linked pitch (Rule 8)
pitch = pitchFar + (pitchNear − pitchFar) × (1 − zoomFraction) where zoomFraction = (zoomDistance − nearClamp) / (farClamp − nearClamp), clamped to [0,1].
| Variable | Type | Range | Description |
|---|---|---|---|
pitch | float | 35°–55° | Camera pitch above horizontal this frame |
pitchNear | float | 55° | Steepest pitch, fully zoomed in (Rule 8 band endpoint) |
pitchFar | float | 35° | Shallowest pitch, fully zoomed out (Rule 8 band endpoint) |
zoomFraction | float | 0.0–1.0 | 0 = fully zoomed in, 1 = fully zoomed out |
Output range: 35°–55°, bounded by construction — zoomFraction is clamped before use, so pitch cannot leave the band even if zoom overshoots during an eased approach.
Example: zoomDistance = 36 → zoomFraction = 0.5 → pitch = 45°.
*Judgement call: linear in zoom distance. Pitch inherits smoothness for free from the zoom easing in §5 rather than needing its own curve. If playtesting shows it flattens too fast in one band, swapping to smoothstep(pitchNear, pitchFar, zoomFraction) is a one-line change.*
4. Cursor-anchored zoom (Rule 12)
newFocalPoint = cursorWorldPoint + (focalPoint − cursorWorldPoint) × (newZoomDistance / oldZoomDistance)
with cursorWorldPoint from a ground-plane raycast: t = −(rayOrigin.y − groundHeight) / rayDirection.y
| Variable | Type | Range | Description |
|---|---|---|---|
newFocalPoint | Vector3 | within pan bounds | Camera pivot after this zoom step |
cursorWorldPoint | Vector3 | on terrain | World point under the cursor |
focalPoint | Vector3 | within pan bounds | Camera pivot before this step |
newZoomDistance / oldZoomDistance | float | 12–60 | This frame's and last frame's zoom distance |
groundHeight | float | map-dependent | Terrain height at the raycast XZ |
Output range: unbounded by this formula alone, but subject to the §2 soft pan-clamp applied afterward. Zooming in near a map edge is softly resisted exactly as keyboard pan would be — no special-case code at the boundary.
Example: focal point (100,0,100), oldZoom = 40, cursor hits (120,0,90), scroll to newZoom = 32. Result: (104, 0, 98) — the pivot moves toward the cursor by exactly the fraction that keeps (120,0,90) under the same pixel.
Critical interaction with §5: this recomputes **every frame during the eased approach**, not once per scroll gesture — otherwise the anchor drifts during the ease. oldZoomDistance is last frame's value and newZoomDistance is this frame's eased intermediate. cursorWorldPoint is re-raycast each frame the cursor moves, mirroring the never-cached principle of Rule 16.
*Judgement call: heightmap-sampled ground plane, not full mesh collision. A full terrain raycast every frame during a zoom ease is unnecessary precision and risks hitting buildings instead of ground. Confirm with the terrain system owner once that GDD exists.*
5. Eased interpolation (Rules 7, 11)
Zoom is continuous input, Q/E is discrete. They use different easing, matched to how each behaves. (Decision confirmed by the user, 2026-09-21.)
5a — Zoom: exponential decay (frame-rate independent)
currentZoom = targetZoom + (currentZoom − targetZoom) × e^(−deltaTime / zoomTimeConstant)
| Variable | Type | Range | Description |
|---|---|---|---|
currentZoom | float | 12–60 | This frame's actual zoom, updated in place |
targetZoom | float | 12–60 | Where scroll input wants to end up; set instantly on input |
zoomTimeConstant | float (tuning) | 0.15s default | Time to close 63% of remaining distance |
Output range: approaches targetZoom asymptotically; snap when the remaining delta is below ~0.01 units to avoid infinite micro-updates. Bounded to [12,60] because targetZoom is clamped at input time (Rule 13).
Example: currentZoom = 40, targetZoom = 30, deltaTime = 0.0167 → 30 + 10 × e^(−0.111) = 39.05. After ~0.15s, 63% of the gap is closed; after ~0.45s, over 95%.
Scrolling again mid-ease simply moves targetZoom — the approach redirects smoothly rather than restarting or queueing.
*Judgement call: 0.15s chosen to match Q/E's specified 150ms, so both feel like they belong to the same camera. Needs playtest validation.*
5b — Q/E rotation: fixed-duration smoothstep
currentYaw = lerpAngle(startYaw, targetYaw, smoothstep(0, 1, elapsed / rotationDuration))
| Variable | Type | Range | Description |
|---|---|---|---|
currentYaw | float | 0°–360° | Yaw during the eased step |
startYaw | float | 0°–360° | Yaw when Q/E was pressed |
targetYaw | float | 0°–360° | startYaw ± 45°, normalised |
rotationDuration | float | 0.15s (Rule 7) | Total step duration |
Output range: moves start → target over exactly rotationDuration, then holds. A discrete step gets a definite endpoint rather than trailing off. lerpAngle must take the shortest path (±45°, never 315° the long way) and wrap correctly through 0°/360°.
Example: startYaw = 20°, press E → targetYaw = 65°. At `elapsed = 0.075s, smoothstep(0,1,0.5) = 0.5 → currentYaw = 42.5°`.
Interrupt: pressing Q/E again mid-ease restarts from the current yaw with a fresh 45° target — rapid taps stay responsive rather than queueing. (Decision confirmed by the user, 2026-09-21; also recorded in Edge Cases.)
6. World-to-screen bearing (Rule 15)
screenBearing = mod(worldBearing − cameraYaw + 360, 360)
| Variable | Type | Range | Description |
|---|---|---|---|
screenBearing | float | 0°–360° (360 wraps to 0) | Threat direction relative to the current view. 0° = top of screen, 90° = right, 180° = behind, 270° = left |
worldBearing | float | 0°–360° | Threat's absolute world direction from the settlement; 0° = world north |
cameraYaw | float | 0°–360° | Current camera yaw (Rule 17) |
Output range: 0°–360°, bounded by the modulo. The +360 before the modulo handles negative differences — C#'s % can return negative results, so worldBearing=10°, cameraYaw=350° must resolve to 20°, not −340°.
Example: raid at world bearing 47°, camera yawed to 300° → mod(47−300+360, 360) = 107°, reading as screen-right. Rotate the camera to 340° and it recomputes to 67° — the indicator sweeps left as the player rotates right, which is the correct relative motion.
*No judgement call — this is Rule 15's stated expression with the normalisation mechanics an implementer needs made explicit.*
Default tuning values
| Constant | Default | Status |
|---|---|---|
basePanSpeed | 15 units/s | Judgement — tune in engine |
referenceZoomDistance | 24 units | Judgement — pan-speed anchor |
| Soft-clamp band | 10% of bounds | Given by Rule 3 |
| Soft-clamp easing | smoothstep | Judgement — smoother at the seam |
nearClamp | 12 units | Unvalidated — needs settler model scale |
farClamp | 60 units | Unvalidated — needs valley dimensions |
pitchNear / pitchFar | 55° / 35° | Given by Rule 8 |
| Pitch interpolation | linear in zoom distance | Judgement — smoothstep is the fallback |
zoomTimeConstant | 0.15s | Judgement — matches Q/E duration |
rotationDuration | 0.15s | Given by Rule 7 |
| Cursor-anchor raycast | heightmap, not full mesh | Judgement — perf; confirm with terrain owner |
Edge Cases
| Scenario | Expected Behaviour | Rationale |
|---|---|---|
| Q/E pressed again mid-ease | Restart the ease from the current yaw with a fresh 45° target. Do not queue. | Rapid taps stay responsive; queueing would feel laggy and defeat Rule 7's stated purpose of low-effort predictable framing. (User decision, 2026-09-21; see Formulas §5b.) |
| Scroll input at a zoom clamp | Rejected outright. No rubber-banding, no error state, no blocking of other input. | Rule 13. Rubber-banding would imply the clamp is soft when it is not. |
| Cursor-anchored zoom would push the focal point outside pan bounds | The soft pan-clamp (§2) applies exactly as it would to keyboard pan. | Formulas §2 and §4 compose — no special-case code needed at the boundary. |
| Cursor is over sky or void when scroll fires | Fall back to zooming toward the current focal point, no anchor. | A raycast that hits nothing cannot anchor. A silent fallback beats a visible stutter. |
| Window loses focus mid-drag (rotate or pan) | Cancel the drag. The camera holds its current transform. | Prevents a stuck "still dragging" state when the player alt-tabs. |
| Edge-scroll while a modal UI is open | Suppressed. | Rule 4. |
| Drag-select and RMB-rotate at the same time | Both proceed. | Different buttons, no conflict — explicitly permitted by the Drag-Select state row. |
| Muster nudge fires while the player is mid-drag | The nudge is skipped entirely, not queued. | The player is actively looking somewhere on purpose. Interrupting them — or interrupting them late — is exactly the "fighting the player" failure Rule 18 forbids. This applies Rule 18's principle to a case the rule does not literally cover. (User decision, 2026-09-21.) |
| Terrain heightmap unavailable at the cursor XZ | Treat ground height as 0 for the anchor raycast. | Degrades to flat-plane behaviour rather than failing the zoom outright. |
deltaTime spike (frame hitch) | Exponential decay (§5a) is frame-rate independent by construction. The fixed-duration Q/E ease (§5b) clamps elapsed to rotationDuration. | A 200ms hitch must not teleport the camera or overshoot the 45° target. |
Dependencies
Upstream
None. Camera & Input is a Foundation-layer system with nothing above it. It is the first system that can be built and the first that must work.
Downstream
All three are hard dependencies — none can function without the camera existing.
| System | Type | Interface consumed |
|---|---|---|
| Pathfinding & Movement | Hard | ScreenPointToWorldRay() for click/hover target resolution |
| Building Placement & Construction | Hard | Same raycast utility, plus the EnterPlacementPreview() / ExitPlacementPreview() mode handshake |
| Threat Forecasting UI | Hard | GetCameraYaw(), WorldBearingToScreenBearing(), GetViewFrustumWorldBounds() |
Bidirectional consistency
The systems index lists all three as depending on Camera & Input, and this GDD lists them as dependents — consistent in both directions.
None of the three have GDDs yet. When each is written it must declare Camera & Input as an upstream dependency, and the interfaces above are the contract it inherits. The full ownership split for Threat Forecasting UI is in Detailed Design → Interactions.
Note for the Threat Forecasting UI author: the systems index row for your
system carries a camera-yaw constraint added on 2026-09-14
("threat is directional; the indicator must stay correct under arbitrary
camera yaw"). That constraint is resolved here — callWorldBearingToScreenBearing()every frame you redraw and the problem
cannot occur. Do not track camera yaw independently; that would create a
second source of truth for the camera angle.
Tuning Knobs
Every knob here traces to a formula variable in Section D — one source of truth per value, no duplicates.
| Knob | Default | Safe range | Too low | Too high |
|---|---|---|---|---|
basePanSpeed | 15 u/s | 8–30 | Crossing the valley feels like wading | Overshoots targets; hard to stop precisely |
referenceZoomDistance | 24 u | 18–36 | Pan feels too fast when zoomed in | Pan feels sluggish when zoomed out |
edgeScrollMargin | 12 px | 4–24 | Hard to trigger deliberately | Fires accidentally when reaching for UI |
nearClamp | 12 u | unvalidated | Clips into buildings; loses spatial context | Cannot read settler silhouettes — breaks "Silhouette first" |
farClamp | 60 u | unvalidated | Cannot see the valley — breaks "readable at strategic zoom" | Settlement becomes illegible specks |
pitchNear | 55° | 50–60 | — | Steepens toward top-down, loses building silhouettes |
pitchFar | 35° | 30–40 | Flattens toward side-on, loses the sense of overlooking | — |
zoomTimeConstant | 0.15 s | 0.08–0.3 | Reads as an instant snap — violates Rule 11 | Feels laggy, as if the camera is on elastic |
rotationDuration | 0.15 s | 0.1–0.25 | Jarring 45° jump | Rapid taps feel unresponsive |
rotationDragSensitivity | tune in engine | — | Requires huge mouse sweeps to turn | Twitchy; hard to frame deliberately |
softClampBand | 10% | 5–20% | Abrupt stop at map bounds | Camera feels sticky well before the edge |
Interacting knobs
basePanSpeedandreferenceZoomDistanceare coupled. Changing one without the other shifts the zoom level at which panning feels correct. Tune them as a pair.zoomTimeConstantshould stay nearrotationDuration. The rationale for 0.15s was that both should feel like they belong to the same camera; letting them drift apart undoes that.nearClampandfarClampare not independent of the art. Both encode visual-identity requirements ("read individual silhouettes", "readable at strategic zoom") that cannot be evaluated until settler models and terrain exist. Treat the defaults as placeholders — see Open Questions.
Visual/Audio Requirements
Camera & Input produces no art assets of its own, but it owns the three levers that decide whether the Visual Identity Anchor's promises are keepable: pitch, zoom range, and framing.
Constraints this system imposes on art
- Silhouette legibility at
nearClamp. The Anchor's "Silhouette first" principle requires a settler's role and gear to be identifiable from outline alone at the closest zoom. If models cannot achieve that at 12 units, either the models ornearClampmust change — this GDD does not get to decide which, but it does get to flag the conflict. - Readability at
farClamp. "Readable at strategic zoom" means the whole valley plus a threat buffer beyond the palisade must be legible at 60 units. Buildings that dissolve into noise at that distance fail the rule. - Pitch band is a lighting constraint, not just a camera one. At 35–55° above horizontal, the player sees roofs and upper walls far more than ground detail. Lighting and material work should be weighted accordingly — the Anchor's "warm inside the walls, cold outside" reads primarily off surfaces the camera actually shows at these angles.
Audio
No audio owned by this system. Camera movement is silent — no whoosh on pan, rotate, or zoom. Rationale: the Anchor makes threat an audio-visual signal (the horn, cold light), and camera-movement sound would compete with it for attention every second of play.
📌 Asset Spec — Visual requirements are defined. After the art bible is
approved, run/asset-spec system:camera-inputto check these constraints
against actual asset specs.
UI Requirements
Camera & Input owns no UI. It publishes data that UI systems consume; it draws nothing itself. There is no compass ring, no minimap, no zoom slider owned here (Core Rule 19).
Two requirements it imposes on whoever does own UI:
- **Any world-anchored UI element must call
WorldBearingToScreenBearing()every frame it redraws**, never cache a screen position, and never track camera yaw independently. Caching is the defect the 2026-09-14 UX review predicted; independent tracking creates a second source of truth for the camera angle. - Modal UI must signal focus so edge-scroll can be suppressed (Core Rule 4). A modal that does not announce itself will cause the camera to drift while the player reaches for a button.
No UX spec is required for this system — there is no screen to design. The screens that consume its data (Threat Forecasting UI, the armoury readout, build menus) each need their own.
Acceptance Criteria
Numbering is AC-[category]-[N]. Every criterion is GIVEN/WHEN/THEN with a
named measurable quantity and a tolerance or threshold. Where human judgement
is unavoidable (only AC-VIS-1 and AC-VIS-2), a tester count and numeric pass
fraction are given instead.
This project has a recorded history of untestable criteria — a concept-doc
criterion was rejected on four counts (no definition of its key term, no
operational definition of the behaviour, it scored a mental state rather than
an observable, and it had no tester count or pass threshold). Nothing below
repeats that shape.
Movement (Rules 1–4)
AC-MOVE-1 — Keyboard pan direction. GIVEN Free Look, no modal open, WHEN the tester holds W for 1 second, THEN the focal point moves toward camera- forward projected onto the ground plane, at a distance consistent with AC-MOVE-5's speed, within 5%.
AC-MOVE-2 — Edge-scroll triggers within margin. GIVEN Free Look, window focused, no modal or drag-select, WHEN the cursor is held within edgeScrollMargin px of the right edge for 500ms, THEN the camera pans right starting within 1 frame of entering the margin and stops within 1 frame of leaving it or losing focus.
AC-MOVE-3 — Edge-scroll does not trigger outside margin. GIVEN the same preconditions, WHEN the cursor is held at edgeScrollMargin + 5 px from any edge for 2 seconds, THEN the focal point does not move — 0 displacement, not "small displacement."
AC-MOVE-4 — MMB-drag overrides edge-scroll. GIVEN the cursor is inside the left-edge margin (which would pan left) AND an MMB drag toward the right begins, WHEN the drag is held, THEN the camera pans right for the full duration the button is held.
AC-MOVE-5 — Screen-space-relative pan speed. GIVEN a fixed on-screen reference object visible at two zoom distances, WHEN the tester holds a pan key for exactly 1 second at nearClamp and again at farClamp, THEN the object's on-screen pixel displacement is equal in both trials within 10% — verifying Formula 1's intent that a settler crosses the same fraction of the screen per second at any zoom.
AC-MOVE-6 — Soft clamp damps velocity monotonically. GIVEN the camera pans toward the boundary at max speed, WHEN distanceFromCenter crosses 0.9 × boundsHalfExtent, THEN per-frame displacement decreases monotonically as distance increases, reaching exactly 0 at the boundary, with **no frame in which speed increases while distance increases**.
AC-MOVE-7 — The clamp is soft, not a wall. GIVEN the camera pans at max speed into the boundary, THEN the recorded per-frame velocity sequence over the final 10% of range contains **at least 5 distinct decreasing non-zero values** before reaching 0 at 60fps. A single frame dropping from non-zero to zero is the hard-stop signature and fails.
AC-MOVE-8 — Edge-scroll suppressed under modal UI. GIVEN a modal is open, WHEN the cursor is held in the margin for 2 seconds, THEN the focal point does not move.
AC-MOVE-9 — Edge-scroll suppressed during drag-select. GIVEN a marquee is being drawn, WHEN the cursor enters the margin without releasing, THEN the focal point does not move while the button is held.
Rotation (Rules 5–9)
AC-ROT-1 — Full 360° free-drag range. GIVEN Free Look at yaw 0°, WHEN the tester sweeps an RMB drag until yaw has changed by 400°, THEN yaw passes through every 10° increment at least once (no dead zone, no clamp) and wraps at 360°/0° with no discontinuity larger than the per-frame input delta.
AC-ROT-2 — No snapping on free-drag release. GIVEN an RMB drag in progress, WHEN released at arbitrary yaw values that are not multiples of 45° (10 trials, ≥3 per tester, ≥2 testers), THEN in 0 of 10 trials does yaw move toward the nearest 45° increment after release.
AC-ROT-3 — Q/E steps 45° on the smoothstep curve. GIVEN the camera rests at yaw 20°, WHEN E is pressed once, THEN yaw reaches exactly 65° at elapsed = rotationDuration ± 1 frame, and equals 42.5° ± 0.5° at elapsed = 75ms — the midpoint value Formula 5b predicts.
AC-ROT-4 — Interrupt restarts from current yaw. GIVEN an ease is 60ms into a 150ms transition from 20° toward 65°, WHEN E is pressed again, THEN a new ease begins from the current yaw (not 20°, not 65°) toward currentYaw + 45°, completing rotationDuration after the second press, with no single-frame delta exceeding the maximum smoothstep delta for that duration.
AC-ROT-5 — Pitch is not independently controllable. GIVEN Free Look, WHEN a scripted fuzz pass applies ≥500 random input combinations at constant zoom, THEN pitch matches Formula 3's prediction for the current zoomDistance within 0.1° in every sample. *(Flagged weak by qa-lead: fuzzing catches a leaked pitch path but cannot prove a negative. Treat as a per-build regression pass, not a one-time gate.)*
AC-ROT-6 — Pitch stays in band under rotation. GIVEN rotation at any zoom, WHEN pitch is sampled every frame for 10 seconds of continuous input, THEN 100% of samples fall within 35.0°–55.0° inclusive.
Zoom (Rules 10–14)
AC-ZOOM-1 — Eased, never instant. GIVEN zoomDistance = 40, WHEN the tester scrolls toward 20, THEN sampled zoomDistance matches Formula 5a within 5% at checkpoints 0.05s, 0.15s, 0.3s, 0.45s and 0.6s.
AC-ZOOM-2 — Cursor anchor holds, measured in pixels. GIVEN the cursor over an identifiable world point at screen (Sx, Sy) with zoomDistance = 40, WHEN zoom eases to 32, THEN that world point's screen projection stays within 2px of (Sx, Sy) on every rendered frame of the ease. 2px is sub-perceptible at 1080p and above.
AC-ZOOM-3 — Anchor re-raycasts every frame. GIVEN a zoom ease in progress and the cursor is moved mid-ease without scrolling again, THEN the originally anchored world point stays within AC-ZOOM-2's tolerance while the newly hovered point visibly drifts — proving the anchor is recomputed, not frozen.
AC-ZOOM-4 — Clamps reject, never rubber-band. GIVEN zoomDistance is at nearClamp, WHEN the tester scrolls further in 10 consecutive inputs, THEN zoomDistance shows 0 delta, no error UI appears, and pan and rotate continue to function in the same pass. Repeat symmetrically at farClamp.
AC-ZOOM-5 — Zoom does not alter pan feel. Covered by AC-MOVE-5; both rules depend on the same formula.
AC-ZOOM-6 — Reach changes with zoom, via the shared utility. GIVEN the camera at farClamp, WHEN the tester clicks a point unreachable at nearClamp, THEN ScreenPointToWorldRay() resolves it with no error and no null — confirming Rule 14's intentional reach change is delivered through the shared utility rather than a special case.
AC-ZOOM-7 — Unvalidated clamp values: explicit non-block. GIVEN nearClamp and farClamp are flagged Unvalidated pending assets, WHEN AC-ZOOM-2 through AC-ZOOM-4 run before those assets exist, THEN they **must run and must pass against whatever values are configured** — the mechanism is fully testable today. But AC-VIS-1 and AC-VIS-2 test the chosen numbers against real assets and must be re-run and re-signed once models and terrain land. **Passing the mechanism tests today does not validate 12/60 as final values, and the test report must record which is which.**
Gamepad (Rule 20)
AC-PAD-1 — Gamepad parity on the three mapped verbs. GIVEN a gamepad connected and no keyboard/mouse input, WHEN the tester (a) holds left stick fully right for 1 second, (b) presses RB once from rest at yaw 20°, and (c) holds right trigger from zoomDistance = 40 toward 20 — as 3 separate trials, THEN (a) matches AC-MOVE-5's screen-space displacement within 10%, (b) matches AC-ROT-3 exactly (65° at rotationDuration ±1 frame, and 42.5° ± 0.5° at elapsed = 75ms), and (c) matches AC-ZOOM-1's Formula 5a checkpoints within 5%. *The gamepad path must reuse the same functions, not re-implement them. Identical numbers are the evidence for that: three independent re-implementations would be unlikely to match mouse/keyboard to these tolerances by coincidence, and a shared path cannot silently diverge in a later regression.*
AC-PAD-2 — Screen-centre anchor on gamepad zoom. GIVEN gamepad zoom from zoomDistance = 40 to 32 with an identifiable world point at screen centre, THEN that point stays within 2px of screen centre on every rendered frame of the ease — AC-ZOOM-2's tolerance with centre substituted for cursor. Confirms Formula 4 is shared and only cursorWorldPoint's source differs.
AC-PAD-3 — Mouse-only verbs are absent, not broken. GIVEN a gamepad connected and the cursor parked inside edgeScrollMargin, WHEN no mouse input is given for 2 seconds, THEN the focal point shows 0 displacement — edge-scroll does not fire from a stale cursor position while the player is on gamepad. *This is the failure mode Rule 20's "mouse-only by design" creates if unhandled: a cursor left at a screen edge would pan the camera forever under gamepad play.*
World→Screen Bearing (Rules 15–17, Formula 6) — load-bearing
AC-YAW-1 — Formula correctness at rest. GIVEN camera yaw 300° and a threat at world bearing 47°, WHEN the conversion is queried, THEN it returns 107° ± 0.1°. Repeat for 10 further pairs chosen to cross the 0°/360° wrap in both directions (e.g. worldBearing 10°, yaw 350° → 20°, never −340°), verifying Formula 6's +360 normalisation. All 11 must match within 0.1°.
**AC-YAW-2 — Proof of non-caching. This is the load-bearing criterion of the whole GDD.** GIVEN a threat at a fixed world bearing and the camera performing a continuous RMB sweep for 3 seconds, WHEN the conversion is sampled every rendered frame (≥150 samples at 60fps), each paired with a same-instant GetCameraYaw() reading, THEN every sample satisfies abs(sampled − mod(worldBearing − sampledYaw + 360, 360)) ≤ 0.5°. Non-caching sub-check: partition samples into consecutive pairs where yaw differed between the two frames, even sub-degree. In 0 of those pairs may the bearing be identical. *Why this and not AC-YAW-1 alone: a cached value sampled only at rest is indistinguishable from a live one. A cached or rotation-end-triggered implementation shows the bearing frozen across multiple yaw-changed frames — "stepping" — and only a frame-paired sweep can see it.*
AC-YAW-3 — Yaw query updates same frame. GIVEN the camera rests at yaw 100°, WHEN one discrete rotation input is applied, THEN GetCameraYaw() returns a changed value on the same rendered frame, not the frame after.
AC-YAW-4 — Frustum bounds match the rendered view. GIVEN a known camera state, WHEN GetViewFrustumWorldBounds() is compared against the rendered viewport (a marker at the returned boundary sits at the screen edge; one 1 world unit further is off-camera), THEN bounds match within 1 world unit at all four edges, tested at nearClamp, farClamp and midpoint.
No threat-driven camera constraint (Rule 18) — proving an absence
AC-CONSTRAINT-1 — Rotation unaffected by threat (differential test). GIVEN condition A with no threat active anywhere, and condition B with a threat at world bearing 0° at maximum escalation, WHEN an identical scripted RMB input (same deltas, duration, starting yaw) runs in both, THEN B's yaw-over-time curve matches A's within 0.1° at every frame across 3 seconds at 60 samples/sec — 0 divergence attributable to threat state. *A single-condition "rotation still works" check cannot distinguish "no constraint exists" from "the resistance is too small to notice." Only the diff can.*
AC-CONSTRAINT-2 — No auto-rotation toward or away from threat. GIVEN a threat active and zero rotation input for 10 seconds, THEN yaw shows 0° delta, tested at threat bearings 0°, 90°, 180° and 270°.
AC-CONSTRAINT-3 — No resistance at clamps near threat. GIVEN a threat active and the camera panning into the map boundary, THEN the soft-clamp damping curve is identical with and without the threat, diffed as in AC-CONSTRAINT-1 — confirming Rule 18 covers pan and zoom clamps, not only yaw.
Consumer obligation (UI Requirements)
AC-YAW-5 — Consumers must not cache or independently track. This is a static-analysis criterion, not runtime: "never cached" is a code-shape property external UI must uphold and Camera & Input cannot enforce at its own boundary. GIVEN the Threat Forecasting UI codebase once it exists, WHEN its source is checked for (a) any field persisting a computed bearing or indicator screen position across frames, and (b) any yaw-tracking variable not sourced from GetCameraYaw(), THEN 0 occurrences are found. **Record as BLOCKED, never PASS, until that system exists. Do not mark it satisfied by proxy from this GDD's contract being correct — they are independent failure points.**
Interaction contracts
AC-INT-1 — Raycast utility is pure. GIVEN a fixed camera transform, WHEN ScreenPointToWorldRay(screenPos) is called twice with no state change between, THEN both return identical origin and direction within float epsilon 1e-5.
AC-INT-2 — Camera never reads back from Pathfinding. GIVEN Pathfinding performs any operation with player input disabled, THEN camera position, yaw, pitch and zoomDistance show 0 delta.
AC-INT-3 — Placement Preview keeps camera live. GIVEN EnterPlacementPreview() is active, WHEN the tester pans, zooms and rotates, THEN all three produce results identical to AC-MOVE-1, AC-ZOOM-1 and AC-ROT-1 re-run under this precondition — 0 behavioural difference.
AC-INT-4 — Placement captures left-click. GIVEN Placement Preview active, WHEN the tester left-clicks open ground where a marquee would normally begin, THEN no Drag-Select state is entered (edge-scroll remains enabled, unlike AC-MOVE-9) and the click reaches Building Placement's footprint-check path.
AC-INT-5 — Mode handshake is symmetric. GIVEN Free Look, WHEN EnterPlacementPreview() then ExitPlacementPreview() are called with nothing between, THEN input routing is observably identical to before — verified by AC-INT-4's marquee check now succeeding again.
AC-INT-6 — Threat UI queries are read-only. GIVEN a stub caller invokes all three published queries 1,000 times in a loop, THEN camera position, yaw, pitch and zoomDistance are bit-identical before and after.
State transitions
AC-STATE-1 — Free Look → Placement Preview. GIVEN Free Look, WHEN the build menu opens and a building is selected, THEN the state machine reports Placement Preview and a ghost follows the cursor within 1 frame of a mouse-move.
AC-STATE-2 — Placement Preview → Free Look, three exit paths. GIVEN Placement Preview, WHEN (a) confirmed, (b) cancelled via UI, or (c) Esc — as 3 separate trials, THEN all 3 return to Free Look within 1 frame and left-click routes to selection again.
AC-STATE-3 — Free Look → Drag-Select. GIVEN Free Look with the cursor over open ground, WHEN left-click is held and the mouse moves, THEN the state machine reports Drag-Select and a marquee renders from click-origin to cursor.
AC-STATE-4 — Drag-Select → Free Look on release. GIVEN Drag-Select, WHEN the button is released, THEN Free Look resumes within 1 frame and edge-scroll re-enables — verified by AC-MOVE-2 passing immediately after.
AC-STATE-5 — Free Look → Muster Focus. GIVEN Free Look with no drag in progress, WHEN the muster bell rings, THEN the state machine reports Muster Focus and the eased recentre brings both the palisade world-bounds and the threat's world position inside GetViewFrustumWorldBounds() by completion.
AC-STATE-6 — Any input cancels the nudge. GIVEN the camera mid-nudge, WHEN the tester provides one WASD tap, one scroll tick, or one Q/E press — as 3 separate trials, THEN all 3 halt the nudge immediately (no further eased motion next frame) and report Free Look within 1 frame.
AC-STATE-7 — Nudge completes on its own. GIVEN Muster Focus with no input, WHEN the ease elapses, THEN Free Look resumes automatically and camera state holds steady with 0 further movement.
AC-STATE-8 — Nudge skipped entirely mid-drag. GIVEN the tester is mid RMB or MMB drag, WHEN the bell rings, THEN **0 Muster Focus transition events are recorded**, and after the drag releases there is no eased motion toward any recentre target — distinguishing "skipped" from "delayed."
AC-STATE-9 — Locked/Cutscene unreachable in MVP. GIVEN the MVP build, WHEN all call sites into the Locked/Cutscene entry point are enumerated statically, THEN 0 are found. Static-analysis pass; there is nothing to trigger at runtime.
Performance
AC-PERF-1 — Frame budget under worst-case camera load. GIVEN simultaneous rotate, edge-scroll pan and zoom in a scene at target content density, WHEN frame time is sampled for 30 seconds, THEN p95 frame time ≤ 16.6ms and 0 frames exceed 33ms.
AC-PERF-2 — Draw calls unaffected by camera state. GIVEN a fixed scene, WHEN draw calls are counted in Free Look, Placement Preview, Drag-Select and Muster Focus at identical camera transforms, THEN all 4 stay ≤ 2000 and vary by ≤ 5% between highest and lowest. Large variance indicates a leak or redundant re-render tied to state.
AC-PERF-3 — Bearing conversion cost is negligible. GIVEN the conversion is called once per frame for 50 simultaneous threats — a stress count well above MVP scope, WHEN its total per-frame time is measured, THEN it contributes ≤ 0.1ms. This confirms Rule 16's rationale that there is no performance argument anywhere in this budget for caching.
AC-PERF-4 — Easing math is not a measurable cost. GIVEN the camera idle versus simultaneously easing a zoom and a Q/E rotation, WHEN mean frame time is compared over 5 seconds each, THEN the difference is ≤ 0.5ms.
Frame-hitch robustness
AC-EDGE-1 — Zoom ease survives a hitch. GIVEN zoomDistance = 40, targetZoom = 20, and a 200ms deltaTime injected on one frame, THEN the result matches Formula 5a's prediction for deltaTime = 0.2 within 1% — not an accumulated multi-frame value and not a teleport to target.
AC-EDGE-2 — Q/E ease clamps elapsed under a hitch. GIVEN a 150ms rotation ease 50ms in and a 200ms hitch injected, THEN final yaw is **exactly targetYaw**, with no extrapolated overshoot.
Art-dependent criteria — gated, not blocking
AC-VIS-1 — Silhouette legibility at nearClamp. GIVEN final settler models exist, WHEN ≥5 testers each view 10 settlers of mixed role and gear for 3 seconds in greyscale (silhouette only, no colour cue) and identify role, THEN ≥ 80% of identifications are correct. **Status: BLOCKED — not executable until settler models exist. Record as NOT YET TESTABLE, never PASS or FAIL by default.**
AC-VIS-2 — Readability at farClamp. GIVEN final terrain and buildings exist, WHEN ≥5 testers view the full valley plus threat buffer for 10 seconds and answer a structured yes/no, THEN ≥ 80% report they could distinguish building types and the palisade boundary from noise. Status: BLOCKED — not executable until terrain and buildings exist.
QA flags on this section
- Audio coverage gap. No criterion covers "camera movement is silent" — it is a negative content claim, not a behaviour. Handle as a smoke-check item ("no audio clip bound to pan/rotate/zoom input events") rather than a GIVEN/WHEN/THEN, so it is not silently dropped.
- AC-ROT-5 is weak by construction. Fuzzing catches a leaked pitch path but cannot prove a negative the way AC-CONSTRAINT-1's differential does. Run it every build as a regression pass, not once as a gate.
- Three criteria are correctly un-executable today — AC-YAW-5, AC-VIS-1, AC-VIS-2. They must appear in each sprint QA plan as BLOCKED with an explicit unblock condition. Omission is how a gap like this survives to a release checklist.
- Coverage gap:
rotationDragSensitivityhas no numeric default ("tune in engine"), so no criterion tests it. Add one once a default is set. Known, not silent. - Coverage gap: gamepad stick deadzone and response curve have no defaults (Open Questions #6), so AC-PAD-1 tests stick pan only at full deflection — the one input level where a missing curve cannot show. Partial deflection is untested until a curve is specified. Same shape as
rotationDragSensitivityabove: known, not silent.
Story-type classification for QA planning
| Scope | Type | Gate | Evidence location |
|---|---|---|---|
| Formulas 1–6, Rules 1–19 state behaviour, Rule 16/18 contracts | Logic | BLOCKING | tests/unit/camera-input/ |
| The three interaction contracts | Integration | BLOCKING | tests/integration/camera-input/ |
| Rule 20 gamepad mapping (AC-PAD-1–3) | Integration | BLOCKING | tests/integration/camera-input/ — requires a connected pad or a simulated input device |
| AC-VIS-1, AC-VIS-2 | Visual/Feel | ADVISORY | Screenshot + lead sign-off, once assets exist |
| AC-PERF-1–4 | Integration | BLOCKING | Automated if headless frame-time capture is available; otherwise manual benchmark. Open question for technical-director on tooling before sprint planning commits to "automated." |
Open Questions
Every question here is blocked on an artifact that does not exist yet. That is the honest state of the first GDD in a project with no art, no terrain and no build.
| # | Question | Owner | Resolves when |
|---|---|---|---|
| 1 | Are nearClamp = 12 and farClamp = 60 the right values? | art-director + game-designer | Settler models and terrain exist. Both clamps encode visual-identity requirements ("read individual silhouettes", "readable at strategic zoom") that cannot be evaluated against nothing. Do not treat the defaults as settled. |
| 2 | Should the cursor-anchor raycast sample the heightmap or use full mesh collision? | Terrain system owner | The terrain GDD is written. Heightmap was chosen for performance (Formulas §4); needs confirmation it stays accurate near buildings and slopes. |
| 3 | Does linear zoom-to-pitch feel right across the whole zoom range? | game-designer | First playable build. smoothstep(pitchNear, pitchFar, zoomFraction) is the flagged one-line fallback if pitch flattens too fast in any band. |
| 4 | Does basePanSpeed = 15 suit the actual valley scale? | game-designer | Terrain exists. The real question behind the number: how many seconds should crossing the map take? |
| 5 | Is a minimap needed, and which system owns it? | systems-designer | Threat Forecasting UI is designed. Core Rule 19 establishes that Camera & Input does not own one, but is silent on whether one should exist at all. |
| 6 | Does stick-pan need its own deadzone and response curve, or does Formula 1's zoom-scaling suffice? | game-designer | First gamepad playtest. Stick input is analogue and pressure-varying where keyboard pan is binary, and Formula 1 was tuned against binary input. A deadzone and response curve are likely needed and have no defaults yet — the gamepad twin of rotationDragSensitivity's known coverage gap. |