Operator Camera: 2.5D Top-Down Style

  • Status: accepted
  • Deciders: V-Sekai, fire
  • Tags: V-Sekai, Camera, Operator, 2.5D, Isometric, Jellyfish, 20260425-operator-camera-2-5d

The Context

The virtual creator market operator view needs a readable top-down camera. Three reference games define the design space. Final Fantasy Tactics (1997) (Game Goat 2023) used orthographic projection with four discrete 90° rotation positions. Triangle Strategy (2022) (Nintendo Everything 2021) added free zoom and kept 90° snaps after demo feedback showed continuous rotation disorients grid-map readers. Blue Archive (2021) (Nexon Games 2021) took the opposite approach: fixed diagonal angle, no rotation, smooth auto-follow of the active unit group.

The Problem Statement

The operator console has no camera. It needs two modes: grid survey (Triangle Strategy / FFT) and entity follow (Blue Archive).

Design

Rotation representation: twist/swing per axis in [0, 1]

Camera orientation is expressed as a twist/swing pair, following the same decomposition used by TransformUtil.swing_twist in the humanoid project (addons/humanoid/transform_util.gd):

  • Twist: yaw around the world Y axis, in [0, 1]. 0.0 = north, 0.25 = east, 0.5 = south, 0.75 = west.
  • Swing: elevation pitch, fixed at SWING_ELEVATION = 0.153 (153/1000 of a full turn ≈ 55°). In [0, 1] per axis; only the X-axis (pitch) is non-zero. Swing does not change at runtime.

Both are composed into a single quaternion on CameraPivot:

var twist_q := Quaternion(Vector3.UP,    _twist          * TAU)
var swing_q := Quaternion(Vector3.RIGHT, -SWING_ELEVATION * TAU)
_pivot.quaternion = twist_q * swing_q

Multiplication order twist_q * swing_q means: apply swing_q first in the local (pre-twist) frame, then rotate that frame by twist_q around world Y. This is identical to the swing-twist product in TransformUtil: the twist axis is world Y; swing is the perpendicular component.

SpringArm3D has no rotation of its own — it simply extends along the pivot’s local −Z axis, which already points downward at the correct elevation after the combined quaternion is applied.

In Survey mode the twist snaps to {0.0, 0.25, 0.5, 0.75}. Follow mode locks twist at its current snapped value.

State machine (Lean 4)

-- Twist in [0, 1]; four valid snap positions in Survey mode.
def SNAP_POSITIONS : List Float := [0.0, 0.25, 0.5, 0.75]

def twistSnapped (t : Float) : Prop := t ∈ SNAP_POSITIONS

-- Swing is constant; elevation does not change at runtime.
def SWING_ELEVATION : Float := 0.153  -- corresponds to −55° pitch

inductive CameraMode
  | Survey  -- twist snaps, zoom free, WASD pan
  | Follow  -- twist locked, position lerps to target

structure CameraState where
  mode   : CameraMode
  twist  : Float   -- in [0, 1]
  swing  : Float   -- always SWING_ELEVATION
  zoom   : Float   -- in [ZOOM_MIN, ZOOM_MAX]

-- In Survey mode at rest, twist is always a snap position.
def surveyInvariant (s : CameraState) : Prop :=
  s.mode = CameraMode.Survey → twistSnapped s.twist

-- Swing never changes.
def swingInvariant (s : CameraState) : Prop :=
  s.swing = SWING_ELEVATION

-- Zoom stays in [ZOOM_MIN, ZOOM_MAX] after clamping.
theorem zoom_clamped (s : CameraState) (delta zmin zmax : Float)
    (h : zmin ≤ zmax) (hs : zmin ≤ s.zoom ∧ s.zoom ≤ zmax) :
    let z' := (s.zoom - delta).max zmin |>.min zmax
    zmin ≤ z' ∧ z' ≤ zmax :=
  ⟨le_max_left _ _, min_le_right _ _⟩

Runtime invariants: 1. twist ∈ {0.0, 0.25, 0.5, 0.75} at rest in Survey mode. 2. swing == SWING_ELEVATION always. 3. twist not modified while mode == Follow. 4. ZOOM_MIN ≤ zoom ≤ ZOOM_MAX after every scroll event.

Node hierarchy

OperatorRig (Node3D)    ← pan target; WASD in Survey; lerps to entity in Follow
  CameraPivot (Node3D)  ← swing-twist quaternion applied every frame
    SpringArm3D          ← no rotation; arm length = zoom
      Camera3D           ← PROJECTION_ORTHOGONAL; size mirrors zoom

CameraPivot.quaternion = twist_q * swing_q is rebuilt each frame from the current _twist [0, 1] and the constant SWING_ELEVATION [0, 1]. SpringArm3D carries no rotation; it extends along the pivot’s local −Z.

Survey mode

Q/E increments or decrements twist by 0.25, wrapping at 1.0. The pivot lerps to the new twist value each frame. Scroll adjusts zoom within bounds. WASD pans CameraRig; speed scales with zoom.

Follow mode

F enters Follow mode. CameraRig lerps to the entity each frame. Twist is frozen. SpringArm3D camera lag gives the Blue Archive trailing effect.

Toggle

Tab switches camera.projection between orthographic and perspective.

The Downsides

Orthographic removes depth cues; distinct zone colours compensate. Follow mode exit needs a rotation-unlock animation. The overlay (see 20260425-operator-overlay.md) handles entity density display separately.

The Road Not Taken

Continuous twist in [0, 1]: rejected — operators lose cardinal orientation at non-snap positions (Triangle Strategy lesson). Pure fixed swing with no twist: operators need all four wall faces for zone inspection.

Status

Status: Accepted

Decision Makers

  • iFire

Further Reading

(swingtwist?): addons/humanoid/transform_util.gdswing_twist and swing_twist_inv in the humanoid project.

(Nintendo Everything 2021): “Triangle Strategy details improvements from demo feedback.” Nintendo Everything, 2021. https://nintendoeverything.com/triangle-strategy-details-tons-of-improvements-from-demo-feedback-survey/

(Nintendo Everything 2022): “Triangle Strategy devs on HD-2D.” Nintendo Everything, 2022. https://nintendoeverything.com/triangle-strategy-devs-on-how-the-game-uses-accurate-hd-2d/

(Nexon Games 2021): Blue Archive. Nexon Games, 2021. https://bluearchive.wiki/wiki/Combat_basics

(Udemy 2026): “The Ultimate 2D Top Down Unreal Engine Course.” Udemy. https://www.udemy.com/course/unreal-2d-top-down/

(Unreal MMO Dev 2022): “Rotate and zoom using spring arm.” Unreal MMO Dev, 2022. https://unreal-mmo-dev.com/2022/12/26/30-unreal-engine-handle-zoom-and-rotate-with-spring-arm/

(Godot Engine contributors 2026): “SpringArm3D.” Godot Engine Documentation. https://docs.godotengine.org/en/stable/classes/class_springarm3d.html

(Game Goat 2023): “Designing an isometric 2.5D game like Final Fantasy Tactics Advance.” Game Goat. https://gamegoat.gg/blog/designing-an-isometric-2-5d-game-like-final-fantasy-tactics-advance/29

References

Game Goat. 2023. Designing an Isometric 2.5D Game Like Final Fantasy Tactics Advance. https://gamegoat.gg/blog/designing-an-isometric-2-5d-game-like-final-fantasy-tactics-advance/29.
Godot Engine contributors. 2026. SpringArm3DGodot Engine Documentation. https://docs.godotengine.org/en/stable/classes/class_springarm3d.html.
Nexon Games. 2021. Blue Archive: Combat Basics. https://bluearchive.wiki/wiki/Combat_basics.
Nintendo Everything. 2021. Triangle Strategy: Details of Improvements from Demo Feedback Survey. https://nintendoeverything.com/triangle-strategy-details-tons-of-improvements-from-demo-feedback-survey/.
Nintendo Everything. 2022. Triangle Strategy Devs on How the Game Uses “Accurate” HD-2D. https://nintendoeverything.com/triangle-strategy-devs-on-how-the-game-uses-accurate-hd-2d/.
Udemy. 2026. The Ultimate 2D Top down Unreal Engine Course. https://www.udemy.com/course/unreal-2d-top-down/.
Unreal MMO Dev. 2022. UE: How to Rotate and Zoom Using Spring Arm. https://unreal-mmo-dev.com/2022/12/26/30-unreal-engine-handle-zoom-and-rotate-with-spring-arm/.