DramaticShapeVoxelMod archival mirror · read-only
70243a407b548a72ed6304fe41b0eca7e7e6b042

add third person mode

DramaticShape · 2026-08-03 · parent 399a10a124

add third person mode

Changed files

CHANGELOG.md                  |  45 ++++++++
 README.md                     |  35 ++++++-
 lib/FirstPerson.lua           | 153 ++++++++++++++++++++++++---
 lib/FreeMove.lua              |  40 +++++--
 lib/ThirdPerson.lua           | 349 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 lib/VR.lua                    |   5 +
 lib/VoxelScene.lua            |   8 ++
 lib/VoxelState.lua            |  46 ++++++---
 main.lua                      |  19 ++--
 manifest.json                 |   2 +-
 mod.card                      |  13 ++-
 tests/dramatic_shape_test.lua | 227 ++++++++++++++++++++++++++++++++++++++--
 tests/tp_shots.lua            | 198 +++++++++++++++++++++++++++++++++++
 13 files changed, 1075 insertions(+), 65 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 85cffb8..22d4f77 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,50 @@
 # Changelog
 
+## 1.6.0
+
+### Added
+
+- **A third-person camera: the 3RD rung.** The VOXEL ladder's eighth rung
+  (`3` / SELECT walk onto it after 1ST, and it is on the OPTIONS row)
+  stands the camera on a boom behind the player's shoulder. It is the
+  first-person rig with one number added, so everything 1ST already did
+  it does: the look steers on a mouse, the right stick or a touch drag,
+  and the grid walk is replaced by continuous camera-relative movement --
+  push in any direction and you go there, at any angle, with collision,
+  warps, ledges, encounters and scripts still running through the
+  engine's own machinery.
+
+  The boom **collides**: it marches back through the terrain height field
+  and the map's own walkability, so backing into a wall walks the camera
+  in to your shoulders instead of through it, and rounding a corner eases
+  it back out rather than snapping. Squeezed all the way into the head it
+  simply draws as 1ST until you step clear. It also carries a small
+  over-the-shoulder rail offset, which fades out as the boom shortens.
+
+  Every sprite in the world **turns to face it** -- yours, the NPCs', the
+  figures drawn into the furniture -- and shows the frame it would look
+  like from where the camera actually stands, so walking behind someone
+  shows you their back. Your own character is drawn (with the
+  through-the-wall silhouette 1ST had no use for) and **turns to face
+  where they are walking** rather than where the camera looks, so a strafe
+  reads as one; standing still they come back round to the camera's
+  bearing, which is the one A talks along.
+
+  Your card's frame is chosen from your body's **continuous** bearing
+  rather than from the compass direction the grid game stores. An NPC's
+  facing really is one of four directions, but yours is the angle the
+  camera itself is hung off, and quantising it before measuring it against
+  the eye leaves no margin for the shoulder rail's few degrees of offset:
+  in a band just short of each 45-degree boundary the pair read as 135
+  degrees apart and picked the mirrored PROFILE frame. Standing still.
+  Spinning the camera swept four of those bands a revolution, which showed
+  up as the character flicking sideways for a split second.
+
+  In VR the boom is declined outright and 3RD presents as 1ST does: a
+  headset that seats its wearer three cells behind their own body is a
+  well-known way to make people ill. The rung still changes the walk and
+  the sprites the same way.
+
 ## 1.5.2
 
 ### Added
diff --git a/README.md b/README.md
index f20aa3d..2b79a9f 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,8 @@
 A mod for the [Pokémon Gen 1 Recompilation
 Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project).
 
-The overworld as a voxelized 3D diorama. Also supports experimental first-person and VR.
+The overworld as a voxelized 3D diorama. Also supports experimental
+first-person, third-person and VR.
 
 ## Controls
 
@@ -12,7 +13,7 @@ menu.
 
 | control | does |
 | --- | --- |
-| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → 1ST → OFF (camera pitch) |
+| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → 1ST → 3RD → OFF (camera pitch) |
 | `SELECT` (pad / touch) | the same step as `3` — for the machines with no number row |
 | `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel |
 | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
@@ -23,11 +24,41 @@ menu.
 | the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone |
 | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
 
+## Free-roam cameras (1ST / 3RD)
+
+The last two rungs of the **VOXEL** ladder are experimental, and they are
+the same camera: **1ST** stands it in the player's own eyes, **3RD** pulls
+it back onto a boom behind their shoulder. Both steer, and on both the grid
+walk is replaced by continuous camera-relative movement — push in any
+direction and you go there, at any angle, not just along the four compass
+lines. Collision, warps, ledges, encounters and scripts all still run
+through the engine's own machinery.
+
+| control | does |
+| --- | --- |
+| mouse | look (the cursor is captured; left click is A, right click is B) |
+| right stick | look |
+| a touch drag off the overlay's controls | look |
+| left stick / touch d-pad / arrow keys | walk, relative to where the camera looks |
+
+On **3RD** the boom shortens against whatever is behind you, so backing into
+a wall walks the camera in to your shoulders rather than through it — squeeze
+it all the way in and the view is 1ST until you step clear. The character
+turns to face where they are walking, and every sprite in the world — yours,
+the NPCs', the figures drawn into the furniture — turns to face the camera
+and shows the frame it would look like from where the camera actually
+stands, so walking behind someone shows you their back.
+
 ## VR
 
 The **VR** options row (OFF / ON, off by default) drives a PCVR headset
 through OpenXR on Windows — SteamVR, Oculus or WMR.
 
+Both free-roam rungs put the headset in the player's *head*: a boom that
+seats its wearer three cells behind their own body is a reliable way to make
+people ill, so **3RD** in VR is **1ST** in VR. The rung still changes the
+walk and the sprites the same way.
+
 ### VR controls
 
 Suggested onto Touch, Index and WMR controllers (rebindable in the
diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua
index b997da0..ab30de5 100644
--- a/lib/FirstPerson.lua
+++ b/lib/FirstPerson.lua
@@ -1,4 +1,4 @@
--- Voxel world mode: the first-person camera -- the 1ST rung.
+-- Voxel world mode: the free-roam camera -- the 1ST and 3RD rungs.
 --
 -- Every other rung is the same camera at a different pitch: an orbit over
 -- the view centre, described by one number. 1ST is a different rig
@@ -9,6 +9,13 @@
 -- uniforms, project(), the sky's vanishing line, the water's lean -- reads
 -- eye and focus the same way it always has.
 --
+-- 3RD is that same rig with the eye pulled back onto a boom behind the
+-- player's shoulder (lib/ThirdPerson.lua). Everything in this file is
+-- already general over where the eye stands -- the attitude, the look
+-- inputs, the move intent, the cards that turn to face the eye -- so the
+-- third-person rung is one number applied at the very end of frame(),
+-- rather than a second camera to keep in step with this one.
+--
 -- What this module owns:
 --
 --   the ATTITUDE   yaw and pitch, fed by whichever look input speaks:
@@ -47,6 +54,7 @@ local Mat4 = V.require("Mat4")
 local Voxel = V.require("VoxelState")
 local Voxel3D = V.require("Voxel3D")
 local WorldCurve = V.require("WorldCurve")
+local ThirdPerson = V.require("ThirdPerson")
 
 local FirstPerson = {}
 
@@ -133,9 +141,12 @@ end
 
 -- ------- gates
 
--- Whether the 1ST rung is selected and the 3D pass can carry it.
+-- Whether a free-roam rung -- 1ST or 3RD -- is selected and the 3D pass can
+-- carry it. Both stand the camera with the player, so both read the look
+-- inputs, both walk free, and both turn the cards; how far behind the head
+-- the eye ends up is ThirdPerson's business alone.
 function FirstPerson.engaged()
-  return Voxel.isFirstPerson(Voxel.level) and Voxel3D.available()
+  return Voxel.isFreeCam(Voxel.level) and Voxel3D.available()
 end
 
 -- Whether first person should be READING the player's inputs right now:
@@ -178,7 +189,16 @@ end
 -- deep enough into the blend that the card would fill the lens from
 -- inside. The sun pass keeps drawing it either way -- a first-person
 -- player still throws a shadow on the ground ahead.
+--
+-- Never while 3RD's boom is genuinely out, whatever the blend: the whole
+-- point of a boom is that the character it is booming away from is on
+-- screen. (Nor the silhouette that rides the same answer -- seeing your own
+-- outline through the building you just walked behind is what a
+-- third-person camera owes the player.) A boom SQUEEZED into the head by a
+-- wall answers false there and the card comes out again, because at that
+-- range it is the first-person problem word for word.
 function FirstPerson.hidePlayer()
+  if ThirdPerson.showsPlayer() then return false end
   return FirstPerson.cardBlend() > 0.9
 end
 
@@ -193,15 +213,77 @@ function FirstPerson.lookBy(dyaw, dpitch)
                                 FirstPerson.pitch + dpitch))
 end
 
+-- A bearing as one of the grid's four directions -- the 45-degree
+-- quantisation every facing in this file is made with, in one place so the
+-- compass, the body and the card frames can never disagree about where a
+-- boundary is.
+local function facingOf(a)
+  local s, c = math.sin(a), math.cos(a)
+  if math.abs(s) > math.abs(c) then
+    return s > 0 and "right" or "left"
+  end
+  return c > 0 and "down" or "up"
+end
+
 -- The view direction's flat compass facing, for everything that still
 -- thinks in the grid's four directions: the cell A interacts with, the
 -- sprite the sun sees, the direction a blocked slide bonks in.
 function FirstPerson.compassFacing()
-  local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw)
-  if math.abs(s) > math.abs(c) then
-    return s > 0 and "right" or "left"
+  return facingOf(FirstPerson.yaw)
+end
+
+-- Which way the BODY points, as a continuous world bearing, given the
+-- world-space direction it is walking (0, 0 while standing). In the head,
+-- the body is the head: you face what you look at. On the boom you can see
+-- yourself, and a character sliding sideways while facing the lens reads as
+-- a bug rather than as a strafe -- so a walking body turns to face its own
+-- travel, and a standing one comes back round to the camera's bearing,
+-- which is the one A talks along.
+function FirstPerson.bodyBearing(wx, wz)
+  if ThirdPerson.extended() and wx and wz and (wx ~= 0 or wz ~= 0) then
+    return math.atan2(wx, wz)
   end
-  return c > 0 and "down" or "up"
+  return FirstPerson.yaw
+end
+
+-- The same answer as one of the four facings, which is what the grid game
+-- (and the sprite sheet) reasons in.
+function FirstPerson.bodyFacing(wx, wz)
+  return facingOf(FirstPerson.bodyBearing(wx, wz))
+end
+
+-- ------- the body's live bearing
+--
+-- The bearing the player's own body is actually pointing along RIGHT NOW,
+-- or nil whenever the free walk is not the thing pointing it (a scripted
+-- move, a cutscene, the grid walk with the rung off). FreeMove maintains
+-- it; only the player's own card reads it.
+--
+-- It exists because the card's frame is chosen by the angle BETWEEN the
+-- body and the eye, and quantising the body to a compass direction first
+-- throws away exactly the precision that choice needs. A standing body is
+-- pointed along the camera's own yaw, so the true angle between them is a
+-- flat 180 degrees and the card should show its back and nothing else --
+-- but snap the body to one of four directions on the game tick, then
+-- measure it against an eye that has kept turning since, and the pair can
+-- read as 135 degrees and pick the PROFILE frame instead. Spin the camera
+-- fast and the character flicks to a mirrored side view for a frame or
+-- two. Keeping the bearing continuous gives the measurement a full 45
+-- degrees of slack before it can cross a boundary, which no frame's worth
+-- of turning comes close to spending.
+FirstPerson.bodyYaw = nil
+
+-- Point the body along the direction it is walking (or, standing, along
+-- the camera): records the continuous bearing and hands back the compass
+-- facing the caller wants for p.facing.
+function FirstPerson.pointBody(wx, wz)
+  FirstPerson.bodyYaw = FirstPerson.bodyBearing(wx, wz)
+  return facingOf(FirstPerson.bodyYaw)
+end
+
+-- Hand the body back to whatever else is turning it.
+function FirstPerson.releaseBody()
+  FirstPerson.bodyYaw = nil
 end
 
 -- The unit look direction, and its flat (ground-plane) part.
@@ -234,21 +316,43 @@ function FirstPerson.cardYaw(wx, wz)
   return math.atan2(dx, dz)
 end
 
+-- Which of the four sprite frames a body at world bearing `phi` shows an
+-- eye looking at (wx, wz): the bearing rotated into the viewer's own frame,
+-- quantised. nil when there is no rig to be seen from.
+local function frameFor(phi, wx, wz)
+  local eye = rig and rig.eye
+  if not (eye and phi) then return nil end
+  local dx, dz = eye[1] - wx, eye[3] - wz
+  if dx * dx + dz * dz < 1e-9 then return nil end
+  local rel = wrapPi(phi - math.atan2(dx, dz))
+  local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4
+  return FACING_ORDER[idx + 1]
+end
+
 -- Which of the four sprite frames an entity shows THIS eye: its facing
 -- rotated into the viewer's own frame, quantised. The flat game's frames
 -- are "how this pose looks from the south", so the apparent facing is the
 -- pose rotated by where the viewer actually stands -- walk behind an NPC
 -- and you see their back, circle to their flank and you see the profile,
 -- exactly as the four frames Gen 1 drew intend.
+--
+-- An NPC's facing IS one of the four and nothing finer, so this is the
+-- whole story for everyone in the world except the one body the camera is
+-- attached to -- see playerFacing.
 function FirstPerson.apparentFacing(facing, wx, wz)
-  local eye = rig and rig.eye
-  local phi = FACING_ANGLE[facing]
-  if not (eye and phi) then return facing end
-  local dx, dz = eye[1] - wx, eye[3] - wz
-  if dx * dx + dz * dz < 1e-9 then return facing end
-  local rel = wrapPi(phi - math.atan2(dx, dz))
-  local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4
-  return FACING_ORDER[idx + 1]
+  return frameFor(FACING_ANGLE[facing], wx, wz) or facing
+end
+
+-- The PLAYER's own card, which is the one case where the body's bearing is
+-- known to better than a compass point (bodyYaw, above) -- and the one case
+-- where it matters, because the eye is derived FROM that bearing rather
+-- than independent of it. Measured continuously, a standing body reads as
+-- a flat 180 degrees from its own camera and shows its back, steadily,
+-- however fast the camera is spun. Falls back to the four-direction answer
+-- whenever something other than the free walk is turning the body.
+function FirstPerson.playerFacing(facing, wx, wz)
+  return frameFor(FirstPerson.bodyYaw, wx, wz)
+         or FirstPerson.apparentFacing(facing, wx, wz)
 end
 
 -- ------- the move intent
@@ -344,6 +448,12 @@ function FirstPerson.update(dt)
     rig = nil
   end
 
+  -- the boom, on the same tick and for the same reason: it has to keep
+  -- easing after 3RD is left, and it needs the blend to know whether a
+  -- change of rung is a SLIDE (already inside the world, 1ST <-> 3RD) or
+  -- part of the dive in from the orbit, which carries the eye anyway
+  ThirdPerson.update(dt, FirstPerson.blend)
+
   -- mouse capture follows engagement: captured whenever the rung is on and
   -- the window has focus, released the moment either ends. Checked against
   -- the live mode rather than toggled on edges, so a capture lost to the
@@ -444,6 +554,12 @@ function FirstPerson.frame(me, cx, cy, vw, vh)
                     head[2] + ly * FirstPerson.FOCUS_DIST,
                     head[3] + lz * FirstPerson.FOCUS_DIST }
 
+  -- 3RD: the eye walks back off the head along the very direction it looks,
+  -- as far as the world allows. Fully in (1ST, and every frame of the
+  -- diorama) this hands back the head and the focus untouched, so the two
+  -- rungs are one rig with one number between them.
+  local camEye, camFocus = ThirdPerson.place(head, lx, ly, lz, fpFocus)
+
   local oEye, oFocus, oFov, oUp = orbitRig(cx, cy, vh)
   local function mix(p, q)
     return { p[1] + (q[1] - p[1]) * e,
@@ -462,8 +578,8 @@ function FirstPerson.frame(me, cx, cy, vw, vh)
   local k = WorldCurve.k(vh) * (1 - e)
 
   rig = {
-    eye = mix(oEye, head),
-    focus = mix(oFocus, fpFocus),
+    eye = mix(oEye, camEye),
+    focus = mix(oFocus, camFocus),
     fov = oFov + (FirstPerson.FOV - oFov) * e,
     up = up,
     curve = k,
@@ -503,6 +619,9 @@ function FirstPerson.signature()
     math.floor(b * 64),
     math.floor(FirstPerson.yaw * 64),
     math.floor(FirstPerson.pitch * 64),
+    -- and how far back the boom stands the eye: a wall shortening it moves
+    -- the camera the sun's box is fitted around, standing still or not
+    ThirdPerson.signature(),
   }, ",")
 end
 
diff --git a/lib/FreeMove.lua b/lib/FreeMove.lua
index eacd26a..3c7182a 100644
--- a/lib/FreeMove.lua
+++ b/lib/FreeMove.lua
@@ -1,11 +1,16 @@
--- Voxel world mode: free movement for the first-person rung.
+-- Voxel world mode: free movement for the free-roam rungs.
 --
 -- The engine walks a grid: sixteen frames per cell, four directions,
--- input locked mid-step. Inside a first-person camera that gait reads as
--- riding a rail, so while 1ST drives, this module replaces the WALK and
--- nothing else: the player's position becomes continuous, steered by the
--- camera's own yaw -- push forward and you go where you look, at any
--- angle, sliding along whatever you graze.
+-- input locked mid-step. Inside a camera that stands with the player that
+-- gait reads as riding a rail, so while 1ST or 3RD drives, this module
+-- replaces the WALK and nothing else: the player's position becomes
+-- continuous, steered by the camera's own yaw -- push forward and you go
+-- where you look, at any angle, sliding along whatever you graze.
+--
+-- Both rungs walk identically: the boom behind the shoulder (3RD) changes
+-- where the eye stands, not which way it points, and the walk was always
+-- rotated by the YAW. The one thing it does change is which way the body
+-- POINTS while it moves -- see bodyFacing in the tick.
 --
 -- THE GRID IS STILL THE GAME. Every fact the world cares about is a fact
 -- about cells -- what blocks, what warps, what rustles, what bites -- and
@@ -73,6 +78,11 @@ end
 
 function FreeMove.drop()
   pos = nil
+  -- and the body with it: while something else is walking the player --
+  -- a scripted move, a ledge hop, the grid walk off the rung -- the
+  -- engine's own four-direction facing is the whole truth about which way
+  -- they point, so the card must stop reading our finer one
+  FirstPerson.releaseBody()
 end
 
 -- named for the suite: the module's live position, nil while dropped
@@ -231,8 +241,12 @@ function FreeMove.tick(state)
   local input = Game.input
 
   -- the head is the facing: what A talks to, what the sun's card shows,
-  -- which way a bonk points
-  p.facing = FirstPerson.compassFacing()
+  -- which way a bonk points. (A body that is WALKING may turn along its
+  -- travel instead -- see below, once there is a travel to turn along; a
+  -- standing one always faces where the camera looks, which is what makes
+  -- A predictable.) pointBody rather than compassFacing, so the card also
+  -- gets the CONTINUOUS bearing behind that compass point.
+  p.facing = FirstPerson.pointBody(0, 0)
 
   if input:wasPressed("a") then
     state:interact()
@@ -266,6 +280,12 @@ function FreeMove.tick(state)
 
   if not moving then return end
 
+  -- and once there IS a direction of travel, the body may point along it
+  -- rather than along the head: on the boom (3RD) you can see yourself, so
+  -- a strafe has to look like walking sideways. In the head it is the head
+  -- either way -- bodyBearing says so.
+  p.facing = FirstPerson.pointBody(wx, wz)
+
   state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1)
 
   local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE
@@ -307,8 +327,8 @@ function FreeMove.tick(state)
       FreeMove.drop()
       return
     end
-    -- the push handlers may have turned the facing; the head still rules
-    p.facing = FirstPerson.compassFacing()
+    -- the push handlers may have turned the facing; the walk still rules
+    p.facing = FirstPerson.pointBody(wx, wz)
   end
 end
 
diff --git a/lib/ThirdPerson.lua b/lib/ThirdPerson.lua
new file mode 100644
index 0000000..d146326
--- /dev/null
+++ b/lib/ThirdPerson.lua
@@ -0,0 +1,349 @@
+-- Voxel world mode: the third-person camera -- the 3RD rung.
+--
+-- 3RD is 1ST with the eye pulled off the back of the head. Everything that
+-- makes the first-person rung work -- the steered attitude, the placed
+-- camera on Voxel3D's seam, the cards that turn to face the eye, the
+-- continuous camera-relative walk -- is already general over WHERE the eye
+-- stands, so this module adds exactly one thing to it: a BOOM.
+--
+-- What the boom owns:
+--
+--   the LENGTH    how far behind the pivot the eye sits, eased in and out
+--                 so stepping between 1ST and 3RD slides rather than cuts,
+--                 and clamped every frame by what the world will allow.
+--
+--   the COLLISION a march back along the boom line through the terrain
+--                 height field and the map's own walkability, so backing
+--                 into a wall walks the camera in toward the player's
+--                 shoulders instead of through the wall into the void.
+--                 The recovery is deliberately slower than the intrusion:
+--                 a camera must never be a frame late leaving geometry,
+--                 and must never snap back out the instant a corner clears.
+--
+--   the SHOULDER  the small lateral rail offset that keeps the character
+--                 off dead centre, faded out with the boom so a camera
+--                 jammed against a wall does not also slide sideways into
+--                 it.
+--
+-- Deliberately NOT here: the attitude, the look inputs, the blend, the
+-- move intent (all lib/FirstPerson.lua, which drives this module and reads
+-- its answer while building the frame's rig), and movement itself
+-- (lib/FreeMove.lua, unchanged -- the walk is camera-relative either way,
+-- and the camera's yaw is the same number on both rungs).
+--
+-- Nothing here is required for the rung to draw: with no overworld to ask
+-- (a headless run, the test suite) every query answers "clear" and the boom
+-- extends to its full length over an empty world.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local Voxel = V.require("VoxelState")
+
+local ThirdPerson = {}
+
+-- ------- the boom's numbers
+--
+-- BOOM is world pixels behind the pivot at full extension. A cell is 16 and
+-- a character card is 16 tall, so 48 stands the camera three cells back:
+-- with the first-person lens (65 degrees vertical) that frames the player
+-- at roughly a quarter of the frame height -- the modern action-game
+-- middle ground, close enough to read the four-frame sprite and far enough
+-- to see the cell you are about to walk into.
+--
+-- PIVOT_LIFT raises the orbit point above the first-person eye, so the
+-- boom looks slightly DOWN across the player's shoulder rather than
+-- straight through the back of their head.
+--
+-- SHOULDER is the lateral rail offset, in world pixels, positive to the
+-- camera's right -- which puts the player left of centre, leaving the
+-- larger half of the frame in front of them.
+ThirdPerson.BOOM = 48
+ThirdPerson.PIVOT_LIFT = 4
+ThirdPerson.SHOULDER = 4
+
+-- how long the eye takes to slide out to the boom (and back into the head
+-- when 1ST is picked), in seconds -- the same order as FirstPerson's own
+-- dive so stepping 75 -> 1ST -> 3RD reads as one continuous camera
+ThirdPerson.BOOM_TIME = 0.35
+
+-- ------- the collision's numbers
+--
+-- STEP is how far apart the samples along the boom line are, in world
+-- pixels, and REFINE how many bisections narrow the first blocked one --
+-- four halvings of a 4px step lands the eye within a quarter pixel of the
+-- face, which is finer than the boom ever needs to be.
+--
+-- PAD is the clearance kept between the eye and whatever stopped it. It
+-- has to beat the placed camera's near plane (|eye - focus| * 0.05, which
+-- at full extension is about 3.6 world pixels -- see Voxel3D) or the near
+-- plane clips a hole in the very wall the boom stopped at.
+--
+-- CLEAR is how high above a cell's ground the eye must be to pass OVER
+-- something unwalkable rather than being stopped by it: a fence, a kerb or
+-- a plant pot should not shove the camera in, a building should. Roughly
+-- head height, so the eye clears the props and never the walls.
+ThirdPerson.STEP = 4
+ThirdPerson.REFINE = 4
+ThirdPerson.PAD = 5
+ThirdPerson.CLEAR = 20
+
+-- How fast the boom is allowed to grow BACK once whatever shortened it is
+-- out of the way, in world pixels per second. Shortening is instant (a
+-- camera inside a wall is a hole in the frame); lengthening is rationed,
+-- so rounding a corner eases the eye back out instead of snapping it.
+ThirdPerson.RETURN = 150
+
+-- ------- state
+--
+-- `out` is the eased extension, 0 in the head and 1 fully boomed -- the
+-- number that carries 1ST into 3RD. `len` is the boom's actual length in
+-- world pixels after the world has had its say, which is what place()
+-- stands the eye at and update() eases back toward `want`.
+ThirdPerson.out = 0
+ThirdPerson.len = 0
+ThirdPerson.want = 0
+
+local function ease(t)
+  return t * t * (3 - 2 * t)
+end
+
+-- ------- gates
+
+-- Whether the 3RD rung is the one selected. Not "is the boom out" -- that
+-- is extended() below, which stays true through the ease after the rung is
+-- left, the same way FirstPerson.blend outlives its own rung.
+--
+-- A live headset declines the boom outright: VR builds its own eye cameras
+-- from the tracked pose and never asks place() where to stand, and a
+-- headset that seats its wearer three cells behind their own body is a
+-- well-known way to make people ill. Answering false here is what keeps
+-- everything ELSE the extension decides -- the player's own card, the body
+-- that turns as it walks -- honest about the head VR actually puts you in.
+-- Required lazily and guarded: VR reaches this module through FirstPerson,
+-- and a headless run has no VR module worth loading at all.
+local function headset()
+  local ok, on = pcall(function() return V.require("VR").active() end)
+  return ok and on or false
+end
+
+function ThirdPerson.selected()
+  return Voxel.isThirdPerson(Voxel.level) and not headset()
+end
+
+-- The eased extension, 0 at the head and 1 at the full boom.
+function ThirdPerson.extension()
+  return ease(ThirdPerson.out)
+end
+
+-- Whether the boom is out far enough to be a third-person camera at all --
+-- read off the TARGET extension rather than the live length, so it is
+-- steady while the world shoves the eye about. What the body reads to
+-- decide whether it turns along its own travel.
+function ThirdPerson.extended()
+  return ThirdPerson.extension() > 0.5
+end
+
+-- How far back the eye must ACTUALLY be, in world pixels, for the player's
+-- own card to be worth drawing: a shade under a cell, which is the point
+-- where a 16-pixel card stops being a character and starts being a wall of
+-- pixels across the lens.
+ThirdPerson.SHOW_AT = 14
+
+-- Whether the player's own card belongs in the frame. Not the same
+-- question as extended(): back into a fence and the boom collapses into
+-- the head whatever the rung says, and a card drawn there fills the lens
+-- from inside exactly as it would in first person -- so it comes out, and
+-- the rung reads as first person for as long as the world insists on it.
+function ThirdPerson.showsPlayer()
+  return ThirdPerson.extension() > 0 and ThirdPerson.len >= ThirdPerson.SHOW_AT
+end
+
+-- ------- the world the boom has to fit through
+--
+-- Everything below asks the live overworld and pcall-guards the asking:
+-- with no map (headless, the suite, a frame mid-warp) the boom simply
+-- extends to its full length, which is the right answer for a world with
+-- nothing in it.
+
+local function overworld()
+  local ok, ow = pcall(function()
+    return require("src.core.Game").overworld
+  end)
+  if not ok or not ow or not ow.map then return nil end
+  return ow
+end
+
+-- Which map, and which of its cells, covers a world point. The player's own
+-- map first, then the neighbours the scene streams in around it (same ox/oy
+-- offsets VoxelScene draws them at) -- without that pass the boom would
+-- shorten against "off the map" every time the player walked within three
+-- cells of a route connection, which is most of the time.
+--
+-- nil means no map covers it: genuinely off the world, where the border
+-- ring is drawn and the camera has no business going.
+local function cellAt(ow, wx, wz)
+  local map = ow.map
+  local cx, cy = math.floor(wx / 16), math.floor(wz / 16)
+  if map:inBounds(cx, cy) then return map, cx, cy end
+  for _, nb in ipairs(ow.neighbors or {}) do
+    if nb.map then
+      local nx = math.floor((wx - (nb.ox or 0)) / 16)
+      local ny = math.floor((wz - (nb.oy or 0)) / 16)
+      if nb.map:inBounds(nx, ny) then return nb.map, nx, ny end
+    end
+  end
+  return nil
+end
+
+-- Whether the eye may not stand at this world point. Two refusals, and
+-- they are different questions:
+--
+--   the GROUND is the terrain height field the mesh is actually built from
+--   (VoxelScene.groundAt -- the same answer a character stands on), so a
+--   ledge, a raised bank or a cliff stops the boom exactly where it stops
+--   the geometry, at any pitch.
+--
+--   the WALKABILITY is the map's own, and stands in for everything built
+--   ON the ground that the height field does not describe: house walls,
+--   trees, signs, counters. Held to CLEAR above that cell's ground so the
+--   short furniture of the world is passed over rather than bumped into.
+local function occupied(ow, wx, y, wz)
+  local map, cx, cy = cellAt(ow, wx, wz)
+  if not map then return true end
+  local VoxelScene = V.require("VoxelScene")
+  local okG, gh = pcall(VoxelScene.groundAt, map, cx, cy)
+  gh = (okG and gh) or 0
+  if y < gh + ThirdPerson.PAD then return true end
+  local okW, walkable = pcall(function() return map:isWalkableCell(cx, cy) end)
+  if okW and not walkable and y < gh + ThirdPerson.CLEAR then return true end
+  return false
+end
+
+ThirdPerson._occupied = occupied       -- named for the suite
+
+-- How far back along (bx, by, bz) from `pivot` the eye can stand, up to
+-- `want`. March at STEP, and when a sample refuses, bisect back into the
+-- gap between it and the last clear one -- so the answer is the face's own
+-- position rather than the sampling grid's, and walking toward a wall
+-- draws the camera in smoothly instead of in four-pixel jerks. PAD comes
+-- off whatever survives.
+function ThirdPerson.reach(ow, pivot, bx, by, bz, want)
+  if not ow or want <= 0 then return math.max(0, want) end
+  local function clear(t)
+    return not occupied(ow, pivot[1] + bx * t, pivot[2] + by * t,
+                        pivot[3] + bz * t)
+  end
+  local lo = 0
+  local steps = math.ceil(want / ThirdPerson.STEP)
+  local hi = nil
+  for i = 1, steps do
+    local t = math.min(want, i * ThirdPerson.STEP)
+    if clear(t) then
+      lo = t
+    else
+      hi = t
+      break
+    end
+  end
+  if not hi then return want end
+  for _ = 1, ThirdPerson.REFINE do
+    local mid = (lo + hi) / 2
+    if clear(mid) then lo = mid else hi = mid end
+  end
+  return math.max(0, lo - ThirdPerson.PAD)
+end
+
+-- ------- the tick
+--
+-- Rides FirstPerson.update, which is itself on the pipeline's own update
+-- hook, so this runs every frame whatever the rung -- the extension has to
+-- keep easing back in after 3RD is left. `blend` is FirstPerson's dive into
+-- the head: while it is fully out (the diorama), the extension SNAPS to its
+-- target rather than easing, so picking 3RD from an orbit rung is one
+-- motion (the dive) rather than two (a dive, then a slide backwards).
+function ThirdPerson.update(dt, blend)
+  local target = ThirdPerson.selected() and 1 or 0
+  if (blend or 0) <= 0 then
+    ThirdPerson.out = target
+    ThirdPerson.len = ThirdPerson.BOOM * target
+    -- and the wanted length with it: place() is what normally maintains it
+    -- and it does not run at all while the rig is out of the frame, so a
+    -- stale want left here would have the recovery below creeping the boom
+    -- back out over a camera that is not on screen
+    ThirdPerson.want = ThirdPerson.len
+  else
+    local step = dt / ThirdPerson.BOOM_TIME
+    if ThirdPerson.out < target then
+      ThirdPerson.out = math.min(target, ThirdPerson.out + step)
+    elseif ThirdPerson.out > target then
+      ThirdPerson.out = math.max(target, ThirdPerson.out - step)
+    end
+  end
+
+  -- the rationed recovery: place() already pulled `len` in to whatever the
+  -- world allowed this frame, and this is the only thing that lets it back
+  -- out again
+  if ThirdPerson.len < ThirdPerson.want then
+    ThirdPerson.len = math.min(ThirdPerson.want,
+                               ThirdPerson.len + ThirdPerson.RETURN * dt)
+  end
+end
+
+-- ------- the eye
+--
+-- Where the camera stands, given the pivot the first-person rig would have
+-- put the eye at and the unit look direction it would have looked along.
+-- Returns the eye and the focus: both slide by the shoulder offset, so the
+-- view direction is untouched and only the frame's contents shift.
+--
+-- With the boom fully in this is exactly the first-person answer, to the
+-- pixel -- which is what makes 1ST and 3RD one rig with a number between
+-- them rather than two cameras to keep in sync.
+function ThirdPerson.place(pivot, lx, ly, lz, focus)
+  local e = ThirdPerson.extension()
+  if e <= 0 then
+    ThirdPerson.want, ThirdPerson.len = 0, 0
+    return pivot, focus
+  end
+
+  local up = ThirdPerson.PIVOT_LIFT * e
+  local orbit = { pivot[1], pivot[2] + up, pivot[3] }
+
+  local want = ThirdPerson.BOOM * e
+  ThirdPerson.want = want
+  local room = ThirdPerson.reach(overworld(), orbit, -lx, -ly, -lz, want)
+  -- in instantly, out only as fast as update() allows
+  ThirdPerson.len = math.min(ThirdPerson.len, room)
+  local len = ThirdPerson.len
+
+  -- the rail offset, faded with how much boom actually survived: a camera
+  -- squeezed against a wall gives up its shoulder before it gives up its
+  -- distance. Right of the look, flat: cross(look, worldUp) normalized,
+  -- which for a look of (sin y, *, cos y) is (-cos y, 0, sin y) -- the same
+  -- right hand FirstPerson.moveWorld strafes along.
+  local flat = math.sqrt(lx * lx + lz * lz)
+  local sx, sz = 0, 0
+  if flat > 1e-6 then
+    local s = ThirdPerson.SHOULDER * e * (len / math.max(want, 1e-6))
+    sx, sz = -lz / flat * s, lx / flat * s
+  end
+
+  local eye = { orbit[1] - lx * len + sx,
+                orbit[2] - ly * len,
+                orbit[3] - lz * len + sz }
+  local aim = focus and { focus[1] + sx, focus[2] + up, focus[3] + sz }
+              or nil
+  return eye, aim
+end
+
+-- What a shadow signature has to include about the boom: the sun's box is
+-- fitted around this camera, so sliding the eye back (or having a wall
+-- shove it in) re-fits it even standing still.
+function ThirdPerson.signature()
+  if ThirdPerson.extension() <= 0 then return "" end
+  return math.floor(ThirdPerson.len) .. "/" ..
+         math.floor(ThirdPerson.extension() * 64)
+end
+
+return ThirdPerson
diff --git a/lib/VR.lua b/lib/VR.lua
index e00e149..81e6596 100644
--- a/lib/VR.lua
+++ b/lib/VR.lua
@@ -257,6 +257,11 @@ local function renderWorld(views, ctl)
   VoxelScene.spriteLean = math.rad(75)
 
   local pivot, anchor, scale, mountYaw
+  -- Either free-roam rung puts the headset in the player's head: 3RD's boom
+  -- is a FLAT-SCREEN framing device, and a headset that stands its wearer
+  -- three cells behind their own body is a well-known way to make people
+  -- ill. The rung still changes the walk and the cards the same way; only
+  -- the eye stays where a head belongs.
   local fp = FirstPerson.engaged()
   local battle, battleFloor
   if camMode == "battle" then battle, battleFloor = battleStage() end
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index 19179a2..0b0819a 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -226,8 +226,16 @@ end
 -- reads its own shadowing with must describe the same frame, or the
 -- mirror-flip half of the pair asks the map about texels the sun filed
 -- under the other cheek.
+-- The player's own card asks a different function for the same answer:
+-- their body's bearing is what the camera is derived FROM, so it is known
+-- continuously rather than as one of four directions, and measuring
+-- against the compass point instead flicks the card to a profile for a
+-- frame or two when the camera is spun fast (see playerFacing).
 local function viewFacing(p)
   if FirstPerson.cardBlend() > 0.5 then
+    if p.isPlayer then
+      return FirstPerson.playerFacing(p.facing, p.px + 8, p.py + 8)
+    end
     return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8)
   end
   return p.facing
diff --git a/lib/VoxelState.lua b/lib/VoxelState.lua
index 3e8bb45..e00e188 100644
--- a/lib/VoxelState.lua
+++ b/lib/VoxelState.lua
@@ -33,17 +33,18 @@ local Voxel = {}
 -- in the table is deliberate: the ladder is a list of what each rung LOOKS
 -- like, and two rungs may look the same while meaning different things.
 --
--- 1ST is the other rung that is more than an angle: the camera steps off its
--- orbit entirely and stands in the player's own eyes (lib/FirstPerson.lua),
--- with free look and free movement. Its ANGLE entry is 75 -- the orbit rung
--- it hands over from -- because the tween in and out of first person starts
--- from whatever the orbit shows, and the lowest rung is the one a dive into
--- a head should start from. Everything angle-derived (the sky's fade, the
--- billboard lean the blend eases away) reads that 75 while the first-person
--- rig owns the actual camera.
-Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75 }
+-- 1ST and 3RD are the other rungs that are more than an angle: the camera
+-- steps off its orbit entirely and stands with the player -- in their eyes
+-- (lib/FirstPerson.lua), or on a boom behind their shoulder
+-- (lib/ThirdPerson.lua) -- with free look and free movement on both. Their
+-- ANGLE entries are 75 -- the orbit rung they hand over from -- because the
+-- tween in and out starts from whatever the orbit shows, and the lowest rung
+-- is the one a dive into a head should start from. Everything angle-derived
+-- (the sky's fade, the billboard lean the blend eases away) reads that 75
+-- while the free-roam rig owns the actual camera.
+Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75, 75 }
 Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75",
-                       "1ST (EXPERIMENTAL)" }
+                       "1ST (EXPERIMENTAL)", "3RD (EXPERIMENTAL)" }
 Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
 
 -- the rung FULL sits on, so nothing has to hunt for it by label
@@ -60,6 +61,23 @@ function Voxel.isFirstPerson(level)
   return (level or Voxel.level) == Voxel.FP_LEVEL
 end
 
+-- and the third-person one, which is the same rig with the eye boomed off
+-- the back of the head (lib/ThirdPerson.lua)
+Voxel.TP_LEVEL = 7
+
+function Voxel.isThirdPerson(level)
+  return (level or Voxel.level) == Voxel.TP_LEVEL
+end
+
+-- The two of them together: the rungs where the camera stands WITH the
+-- player rather than orbiting the view centre, which is what decides that
+-- the look inputs are read, the walk goes free and the cards turn to face
+-- the eye. Everything that used to ask isFirstPerson for those asks this.
+function Voxel.isFreeCam(level)
+  level = level or Voxel.level
+  return Voxel.isFirstPerson(level) or Voxel.isThirdPerson(level)
+end
+
 -- ------- what the hotkey walks
 --
 -- The ANGLE rungs only, with FULL left out. The key is a display-mode
@@ -69,11 +87,11 @@ end
 -- with no indication that a keypress had done so. FULL stays on the OPTIONS
 -- row, which is where a preset that changes other rows belongs.
 --
--- 1ST is on the path: it changes the camera and only the camera, which is
--- exactly what the key promises -- and the key is also the way back OUT of
--- first person on a keyboard, where the mouse is captured and the OPTIONS
+-- 1ST and 3RD are on the path: they change the camera and only the camera,
+-- which is exactly what the key promises -- and the key is also the way back
+-- OUT of them on a keyboard, where the mouse is captured and the OPTIONS
 -- menu is a trip.
-Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6 }   -- OFF, 15, 35, 50, 75, 1ST
+Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6, 7 }  -- OFF,15,35,50,75,1ST,3RD
 
 -- The rung a press moves to from `level`.
 --
diff --git a/main.lua b/main.lua
index 1fff18a..8f2d776 100644
--- a/main.lua
+++ b/main.lua
@@ -24,10 +24,11 @@
 -- below.  This file declares; lib/ draws.
 --
 -- Voxel mode is presentational: it changes what the world LOOKS like and
--- nothing about what it IS.  ONE rung is the deliberate exception. 1ST --
--- the first-person camera -- replaces the grid WALK with a free,
--- camera-relative one while it is selected (lib/FreeMove.lua), because a
--- head you can steer with a mouse demands feet that go where it looks.
+-- nothing about what it IS.  TWO rungs are the deliberate exception. 1ST
+-- (the camera in the player's own eyes) and 3RD (the same rig, boomed back
+-- behind their shoulder) replace the grid WALK with a free,
+-- camera-relative one while either is selected (lib/FreeMove.lua), because
+-- a camera you can steer with a mouse demands feet that go where it looks.
 -- Even there the game is untouched: the walk asks the engine's own
 -- collision the same questions a grid step asks, keeps the player's
 -- logical cell synced, and fires the engine's own landing pipeline per
@@ -860,9 +861,11 @@ end
 -- so this file keeps naming every engine seam the mod touches.
 OverworldBattle.install()
 
--- ------- the first-person rung's inputs and its walk
+-- ------- the free-roam rungs' inputs and their walk
 --
--- 1ST needs two things no other rung does, and each is a named seam:
+-- 1ST and 3RD need two things no other rung does, and each is a named seam.
+-- Both rungs are one rig -- the boom behind the shoulder is a number inside
+-- it (lib/ThirdPerson.lua) -- so both are installed by the same two calls:
 --
 -- FirstPerson.install claims the LOOK inputs the engine ignores: the right
 -- stick's axes (Game:gamepadaxis passes them to Input, which returns early
@@ -874,11 +877,11 @@ OverworldBattle.install()
 -- open screen is the look; the d-pad and buttons still go to
 -- TouchControls, whose own d-pad finger is also read back analog as the
 -- move vector). Every wrap forwards whatever it does not claim, and claims
--- only while 1ST is actually driving.
+-- only while one of the two rungs is actually driving.
 --
 -- FreeMove.install wraps OverworldState:handleInput -- the one choke point
 -- where the grid walk reads the pad, and the same seam the engine's own
--- Cycling Road pull lives behind. While 1ST drives, the walk is continuous
+-- Cycling Road pull lives behind. While either drives, the walk is continuous
 -- and camera-relative; the player's logical cell stays synced and every
 -- per-cell consequence still runs through the engine's own machinery
 -- (onStepComplete, checkEdgeExit, checkLedgeHop, checkBoulderPush). The
diff --git a/manifest.json b/manifest.json
index b8550d8..fdbaac1 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
 {
   "id": "DRAMATIC_SHAPE",
   "name": "Dramatic Shape Voxel Mod",
-  "version": "1.5.2",
+  "version": "1.6.0",
   "api": 2,
   "entry": "main.lua",
   "profile": "content",
diff --git a/mod.card b/mod.card
index 307bdde..35cf629 100644
--- a/mod.card
+++ b/mod.card
@@ -17,11 +17,12 @@ return {
       "the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back",
       "hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded",
       "SELECT in free roam steps the VOXEL ladder exactly as hotkey 3 does -- the button has no overworld job in Gen 1, and phones and pads have no number row; menus keep it untouched",
-      "on the 1ST rung ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched",
-      "on the 1ST rung the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view",
+      "on the 1ST and 3RD rungs ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched",
+      "on the 1ST and 3RD rungs the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view",
+      "on the 3RD rung the character turns to face where they are walking rather than where the camera looks, so a strafe reads as one; standing still they come back round to the camera's bearing, which is the one A talks along",
     },
     added = {
-      "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST, a first-person camera with free look and free movement)",
+      "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST / 3RD -- a first-person camera and a third-person one, both with free look and free movement)",
       "T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur",
       "V-GRID on hotkey 5 and V-CURVE on hotkey 7",
       "WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march",
@@ -44,8 +45,10 @@ return {
       "the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass",
       "menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld",
       "terrain meshes are cached per map, so the first frame after entering a large map costs a build",
-      "1ST needs the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge",
-      "in 1ST, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land",
+      "1ST and 3RD need the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge",
+      "in 1ST and 3RD, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land",
+      "3RD's boom shortens against whatever stands behind the player, so backing into a wall walks the camera in to their shoulders; squeezed all the way in it draws as 1ST until they step clear",
+      "3RD in VR is 1ST in VR: a headset that seats its wearer three cells behind their own body is a well-known way to make people ill, so the boom is declined while a headset is live",
       "rooms have no ceilings, so a first-person look over an interior wall shows the void the diorama always had behind it",
       "VR is Windows x64 only (the shipped loader and the Win32 GL binding): on any other platform -- mobile above all -- the VR row is absent from the OPTIONS menu and the manager's page both, and a stored vr=true carried over in a save is ignored. On Windows it renders the scene once per eye (heavy with WATER FULL or AA up); pad/keyboard/mouse keep working alongside the XR controllers. The loader DLL is found wherever the mod was put -- the dev tree, an installed release's save directory, or an imported archive, from which it is copied once into the save directory so the FFI has a real disk path",
       "VR on and off are both the VR row's job (options menu or manager); no controller button does either",
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index a6fb494..5bf4401 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -49,14 +49,16 @@ T.eq(defs._owners and defs._owners.voxel, "DRAMATIC_SHAPE",
 
 -- ------- the ladders the engine drives
 
-T.eq(#defs.voxel.levels, 7, "voxel exposes a seven-rung ladder")
+T.eq(#defs.voxel.levels, 8, "voxel exposes an eight-rung ladder")
 T.eq(defs.voxel.levels[1], "OFF", "rung 0 is OFF")
 T.eq(defs.voxel.levels[2], "FULL",
   "FULL is the first rung after OFF -- the order those two get used in")
 T.eq(defs.voxel.levels[6], "75", "rung 5 is the 75-degree camera")
 T.eq(defs.voxel.levels[7], "1ST (EXPERIMENTAL)",
-  "the top rung is the first-person camera, labelled as the experiment it is")
-T.eq(Pipelines.maxLevel("voxel"), 6, "the engine reads the ladder height")
+  "rung 6 is the first-person camera, labelled as the experiment it is")
+T.eq(defs.voxel.levels[8], "3RD (EXPERIMENTAL)",
+  "and the top rung is the third-person one, labelled the same way")
+T.eq(Pipelines.maxLevel("voxel"), 7, "the engine reads the ladder height")
 T.eq(Pipelines.levelLabel("voxel", 3), "35", "the engine reads the rung labels")
 
 -- ------- gating: inert until switched on, and inert without a GPU
@@ -1084,12 +1086,14 @@ local Curve = run.loader.exports.DRAMATIC_SHAPE.lib.require("WorldCurve")
 -- with nothing on screen saying a keypress had done it.
 Pipelines.setLevel("voxel", 0)
 local walk = {}
-for _ = 1, 7 do
+for _ = 1, 8 do
   Game.keypressed(keyGame, "3")
   walk[#walk + 1] = Pipelines.levelLabel("voxel")
 end
-T.eq(table.concat(walk, ","), "15,35,50,75,1ST (EXPERIMENTAL),OFF,15",
-  "3 walks OFF -> 15 -> 35 -> 50 -> 75 -> 1ST and wraps, never touching FULL")
+T.eq(table.concat(walk, ","),
+  "15,35,50,75,1ST (EXPERIMENTAL),3RD (EXPERIMENTAL),OFF,15",
+  "3 walks OFF -> 15 -> 35 -> 50 -> 75 -> 1ST -> 3RD and wraps, never "
+  .. "touching FULL")
 
 -- FULL is 35 degrees, so a press from it goes ON to 50 rather than back to
 -- the rung that shows the same camera -- the key never appears to do nothing.
@@ -1176,8 +1180,8 @@ T.eq(GBCFX.level, 0, "and on the live renderer")
 -- TILT with or without us. Park the ladder on its top rung and turn both
 -- back on, so the single press under test is the one that wraps to OFF --
 -- where nothing else is going to clear them.
--- 6 is the "1ST" rung, the last one the key walks before it wraps to OFF
-Pipelines.setLevel("voxel", 6)
+-- 3RD is the last rung the key walks before it wraps to OFF
+Pipelines.setLevel("voxel", VoxelState.TP_LEVEL)
 Tilt.setLevel(3)
 GBCFX.setLevel(4)
 keyGame.save.options.tilt = 3
@@ -3674,6 +3678,213 @@ FirstPerson.blend = 0
 VoxelState.reset()
 end
 
+-- ------- the third-person rung
+--
+-- 3RD is 1ST with the eye on a boom, so what the suite has to hold still is
+-- the boom: where it stands the eye behind a pivot, the march through the
+-- world that shortens it when something is in the way, and the two things
+-- its extension decides that 1ST decides the other way -- the player's own
+-- card being drawn, and the body turning to face where it walks.
+
+do
+local FirstPerson =
+  run.loader.exports.DRAMATIC_SHAPE.lib.require("FirstPerson")
+local ThirdPerson =
+  run.loader.exports.DRAMATIC_SHAPE.lib.require("ThirdPerson")
+local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
+local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
+
+T.eq(VoxelState.TP_LEVEL, 7, "3RD is the eighth rung")
+T.check(VoxelState.isThirdPerson(7), "and isThirdPerson answers for it")
+T.check(not VoxelState.isThirdPerson(6), "but not for 1ST")
+T.check(VoxelState.isFreeCam(6) and VoxelState.isFreeCam(7),
+  "both rungs that stand the camera with the player answer isFreeCam")
+T.check(not VoxelState.isFreeCam(5), "the 75-degree orbit does not")
+T.eq(VoxelState.ANGLE_LABELS[VoxelState.TP_LEVEL + 1], "3RD (EXPERIMENTAL)",
+  "the rung wears the experimental label")
+T.eq(VoxelState.ANGLES_DEG[VoxelState.TP_LEVEL + 1], 75,
+  "and hands the blend the same 75-degree orbit 1ST does")
+T.eq(VoxelState.HOTKEY_ORDER[#VoxelState.HOTKEY_ORDER], VoxelState.TP_LEVEL,
+  "the 3 key walks onto it, and off it back to OFF")
+
+-- the boom's own tween: picked from an orbit rung (blend fully out) the
+-- extension SNAPS, so the dive into the world is one motion rather than a
+-- dive followed by a slide; picked from inside the head it eases
+VoxelState.setLevel(VoxelState.TP_LEVEL)
+ThirdPerson.out, ThirdPerson.len = 0, 0
+ThirdPerson.update(1 / 60, 0)
+T.eq(ThirdPerson.out, 1, "picked from the diorama, the boom starts extended")
+ThirdPerson.out, ThirdPerson.len = 0, 0
+ThirdPerson.update(1 / 60, 1)
+T.check(ThirdPerson.out > 0 and ThirdPerson.out < 1,
+  "picked from inside the head, it slides out over the boom's own time")
+
+-- ------- the world the march asks
+--
+-- A stub map that refuses everything from x = 4 rightward, with no tileset
+-- for the height field to read -- so the ground answers 0 (the lookup is
+-- guarded for exactly this) and only the walkability speaks.
+local wall = { map = {
+  inBounds = function(_, cx, cy) return cx >= 0 and cy >= 0 end,
+  isWalkableCell = function(_, cx) return cx < 4 end,
+  cellTile = function() return 0 end,
+} }
+
+T.check(ThirdPerson._occupied(wall, 70, 10, 8),
+  "an unwalkable cell refuses the eye at head height")
+T.check(not ThirdPerson._occupied(wall, 70, 30, 8),
+  "but not one the eye stands well above -- a fence is not a wall")
+T.check(ThirdPerson._occupied(wall, 70, 2, 8),
+  "and the ground refuses it from below")
+T.check(ThirdPerson._occupied(wall, -20, 30, 8),
+  "off the world entirely there is nowhere to stand: the border ring")
+
+-- the march itself: the wall's face is at x = 64, the pivot at x = 40, so
+-- the eye may travel 24 east of it less the clearance pad -- the FACE's own
+-- position rather than the four-pixel sampling grid's
+local room = ThirdPerson.reach(wall, { 40, 10, 8 }, 1, 0, 0, ThirdPerson.BOOM)
+T.check(math.abs(room - (24 - ThirdPerson.PAD)) < 0.5,
+  "the boom stops a pad short of the face that blocked it")
+T.eq(ThirdPerson.reach(wall, { 40, 30, 8 }, 1, 0, 0, ThirdPerson.BOOM),
+  ThirdPerson.BOOM, "with nothing tall enough in the way it runs out full")
+T.eq(ThirdPerson.reach(nil, { 40, 10, 8 }, 1, 0, 0, ThirdPerson.BOOM),
+  ThirdPerson.BOOM, "and with no world to ask at all -- headless, mid-warp")
+
+-- ------- the eye
+--
+-- place() is pure arithmetic over the pivot and the look, given a world
+-- with nothing in it: the suite lends the overworld away for the length of
+-- the check so the march has nothing to shorten against.
+local Game = require("src.core.Game")
+local hadOw = Game.overworld
+Game.overworld = nil
+
+ThirdPerson.out, ThirdPerson.len = 1, ThirdPerson.BOOM
+local eye, aim = ThirdPerson.place({ 100, 20, 200 }, 0, 0, 1,
+                                   { 100, 20, 224 })
+T.check(math.abs(eye[3] - (200 - ThirdPerson.BOOM)) < 1e-6,
+  "looking south, the eye stands a full boom north of the pivot")
+T.eq(eye[2], 20 + ThirdPerson.PIVOT_LIFT,
+  "raised to the orbit point above the head")
+T.check(math.abs(eye[1] - (100 - ThirdPerson.SHOULDER)) < 1e-6,
+  "and slid along the rail to the camera's own right -- which is west "
+  .. "looking south, and puts the player left of centre")
+T.check(math.abs(aim[1] - eye[1]) < 1e-6
+        and math.abs(aim[3] - eye[3] - (ThirdPerson.BOOM + 24)) < 1e-6,
+  "the focus slides with it, so the rail moves the frame and not the look")
+
+ThirdPerson.out, ThirdPerson.len = 0, 0
+local head = { 100, 20, 200 }
+local focus = { 100, 20, 224 }
+T.eq(ThirdPerson.place(head, 0, 0, 1, focus), head,
+  "with the boom fully in, the eye IS the head -- 1ST to the pixel")
+
+-- ------- what the extension decides
+--
+-- The rig is built through FirstPerson exactly as 1ST's is; the boom moves
+-- the eye, and everything keyed to where the eye stands follows it.
+ThirdPerson.out, ThirdPerson.len = 1, ThirdPerson.BOOM
+FirstPerson.yaw, FirstPerson.pitch = 0, 0
+FirstPerson.blend = 1
+FirstPerson.frame({ px = 100, py = 200, gh = 0, lift = 0 }, 500, 600, 320, 288)
+T.check(FirstPerson.cardBlend() == 1,
+  "the boomed rig turns the cards to face it, exactly as the head does")
+T.check(not FirstPerson.hidePlayer(),
+  "but the player's own card is DRAWN -- it is what the camera is watching")
+T.eq(FirstPerson.apparentFacing("down", 108, 208), "up",
+  "and it shows the camera behind it its back")
+
+-- and a boom a wall has squeezed back into the head takes the card out
+-- again: at that range it is the first-person problem word for word
+ThirdPerson.len = ThirdPerson.SHOW_AT - 1
+T.check(FirstPerson.hidePlayer(),
+  "backed into a fence, the collapsed boom stops drawing the card")
+ThirdPerson.len = ThirdPerson.SHOW_AT
+T.check(not FirstPerson.hidePlayer(), "and draws it again the moment it clears")
+ThirdPerson.len = ThirdPerson.BOOM
+
+T.eq(FirstPerson.bodyFacing(1, 0), "right",
+  "a body walking east turns east, whichever way the camera is pointed")
+T.eq(FirstPerson.bodyFacing(0, -1), "up", "and north walking north")
+T.eq(FirstPerson.bodyFacing(0, 0), FirstPerson.compassFacing(),
+  "standing still it comes back round to the camera's bearing, which is "
+  .. "the one A talks along")
+
+-- ------- the spin-flicker guard
+--
+-- The player's card is the one whose body the camera is derived FROM: the
+-- body is pointed along the camera's own yaw, so the angle between them is
+-- a flat 180 degrees and the card should show its back and nothing else,
+-- at every bearing.
+--
+-- Quantise the body to a compass point first and that stops being true.
+-- The shoulder rail stands the eye a few degrees off the exact rear axis,
+-- and the round trip through four directions has no margin to spare for
+-- it: in a band just short of each 45-degree boundary the pair measures as
+-- 135 degrees and picks the mirrored PROFILE frame. Standing perfectly
+-- still. Spin the camera and you sweep four of those bands a revolution --
+-- the character flicking sideways for a split second, which is the bug
+-- this pair of checks exists to hold shut.
+--
+-- 44 degrees is inside the first band. No lag anywhere: the body is
+-- pointed exactly where the camera looks, which is what standing still IS.
+FirstPerson.yaw, FirstPerson.pitch = math.rad(44), 0
+FirstPerson.frame({ px = 100, py = 200, gh = 0, lift = 0 }, 500, 600, 320, 288)
+FirstPerson.bodyYaw = FirstPerson.yaw
+T.eq(FirstPerson.apparentFacing("down", 108, 208), "right",
+  "measured off the compass point, a standing body picks the profile -- "
+  .. "the flicker, reproduced with the camera perfectly still")
+T.eq(FirstPerson.playerFacing("down", 108, 208), "up",
+  "measured off the body's own bearing, the card keeps its back turned")
+
+-- and the whole revolution, which is the assertion that actually matters:
+-- there is no bearing at all where a standing body shows anything but its
+-- back
+;(function()
+  local wrong = {}
+  for deg = 0, 359 do
+    FirstPerson.yaw = math.rad(deg)
+    FirstPerson.frame({ px = 100, py = 200, gh = 0, lift = 0 },
+                      500, 600, 320, 288)
+    FirstPerson.bodyYaw = FirstPerson.yaw
+    if FirstPerson.playerFacing(FirstPerson.compassFacing(), 108, 208)
+       ~= "up" then
+      wrong[#wrong + 1] = deg
+    end
+  end
+  T.eq(#wrong, 0,
+    "a standing body shows its back at every one of 360 bearings (bad: "
+    .. table.concat(wrong, ",") .. ")")
+end)()
+
+-- and with nothing holding a bearing -- a scripted walk, a cutscene, the
+-- grid walk -- the player falls back to the four-direction answer with
+-- everybody else
+FirstPerson.releaseBody()
+T.eq(FirstPerson.bodyYaw, nil, "releasing the body drops the bearing")
+T.eq(FirstPerson.playerFacing("down", 108, 208),
+  FirstPerson.apparentFacing("down", 108, 208),
+  "and the card reads exactly as an NPC's would")
+T.eq(FirstPerson.pointBody(0, 0), FirstPerson.compassFacing(),
+  "pointing it again hands back the compass facing p.facing wants")
+T.eq(FirstPerson.bodyYaw, FirstPerson.yaw, "and records the bearing behind it")
+FirstPerson.yaw, FirstPerson.pitch = 0, 0
+FirstPerson.frame({ px = 100, py = 200, gh = 0, lift = 0 }, 500, 600, 320, 288)
+
+ThirdPerson.out, ThirdPerson.len = 0, 0
+T.eq(FirstPerson.bodyFacing(1, 0), "down",
+  "in the head the body is the head, whichever way it walks")
+T.check(FirstPerson.hidePlayer(),
+  "and the card the camera stands inside is left out of the frame again")
+
+-- everything the section borrowed, put back
+Game.overworld = hadOw
+FirstPerson.blend = 0
+ThirdPerson.out, ThirdPerson.len, ThirdPerson.want = 0, 0, 0
+Voxel3D.camera = nil
+VoxelState.reset()
+end
+
 -- ------- the VR rig's arithmetic
 --
 -- VRRig is the deliberately pure half of the VR stack: headset poses in,
diff --git a/tests/tp_shots.lua b/tests/tp_shots.lua
new file mode 100644
index 0000000..57e45c7
--- /dev/null
+++ b/tests/tp_shots.lua
@@ -0,0 +1,198 @@
+-- Scratch driver: shots of the 3RD (third-person) rung -- the eye boomed
+-- off the back of the player's head, the player's own card drawn and turned
+-- to face it, the boom shortening against walls indoors, and the body
+-- turning to face where it walks.
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/tp_shots.lua \
+--   SHOT_DIR=.scratchpad/tpshots lovec.exe .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local ROOT = (os.getenv("SHOT_DIR") or "shots/tp")
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    print("[tp] DRAMATIC_SHAPE mod not loaded")
+    return love.event.quit()
+  end
+  local V = handle.lib
+  local FirstPerson = V.require("FirstPerson")
+  local ThirdPerson = V.require("ThirdPerson")
+  local ChunkMesher = V.require("ChunkMesher")
+  local Voxel = V.require("VoxelState")
+  local DayNight = V.require("DayNight")
+
+  pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+  pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+  require("src.world.OverworldController").rollEncounter = function() return nil end
+  local TileRenderer = require("src.render.TileRenderer")
+  TileRenderer.tick = function() end
+  TileRenderer.animFrame = function() return 0 end
+  DayNight.setting:sync("day")
+
+  local Zoom = require("src.render.Zoom")
+  pcall(function()
+    game.save.options.zoom = 0
+    Zoom.applyOptions(game.save.options)
+  end)
+
+  local function settle()
+    for _ = 1, 900 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    for _ = 1, 300 do
+      if FirstPerson.blend >= 1 and Voxel.ready
+         and ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    U.wait(40)
+  end
+
+  -- the nearest WALKABLE cell (fp_shots' helper): a guessed coordinate
+  -- inside a building footprint buries the pivot in the geometry
+  local function place(mapId, x, y)
+    U.teleport(game, mapId, x, y, "down")
+    local ow = game.stack:top()
+    local map = ow and ow.map
+    if not map or map:isWalkableCell(x, y) then return end
+    for r = 1, 8 do
+      for dy = -r, r do
+        for dx = -r, r do
+          if math.max(math.abs(dx), math.abs(dy)) == r then
+            local cx, cy = x + dx, y + dy
+            if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy) then
+              U.teleport(game, mapId, cx, cy, "down")
+              print(("[tp] (%d,%d) not walkable; standing at (%d,%d)")
+                    :format(x, y, cx, cy))
+              return
+            end
+          end
+        end
+      end
+    end
+  end
+
+  -- yaw is a world bearing: 0 south, pi/2 east, pi north, -pi/2 west
+  local SCENES = {
+    -- Pallet Town, mid-street: the player's own card seen from behind, in
+    -- each compass direction plus a diagonal (the off-grid case, where a
+    -- south-facing card would be edge-on to any orbit camera)
+    { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, label = "pallet_north" },
+    { map = "PALLET_TOWN", x = 13, y = 14, yaw = 0, label = "pallet_south" },
+    { map = "PALLET_TOWN", x = 9, y = 7, yaw = math.pi / 2, label = "pallet_east" },
+    { map = "PALLET_TOWN", x = 9, y = 7, yaw = 3 * math.pi / 4,
+      label = "pallet_diag" },
+    -- the shoreline: the boom over water, reflecting the player
+    { map = "PALLET_TOWN", x = 9, y = 12, yaw = 0, label = "pallet_water" },
+    -- pitched down, the classic third-person framing; and up, where the
+    -- boom has to shorten rather than bury the eye in the ground
+    { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
+      pitch = math.rad(35), label = "pallet_down" },
+    { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
+      pitch = -math.rad(40), label = "pallet_skyward" },
+    -- Route 1: grass and ledges, with the character in frame for scale
+    { map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, label = "route1_north" },
+    -- interiors: the one place a 48px boom cannot fit, so the collision
+    -- march is the whole shot
+    { map = "VIRIDIAN_POKECENTER", x = 3, y = 5, yaw = math.pi,
+      label = "center_north" },
+    { map = "REDS_HOUSE_1F", x = 3, y = 4, yaw = math.pi,
+      label = "reds_house" },
+  }
+
+  local shots = 0
+  for _, s in ipairs(SCENES) do
+    place(s.map, s.x, s.y)
+    Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
+    Pipelines.setLevel("tiltshift", 0)
+    settle()
+    FirstPerson.yaw = s.yaw
+    FirstPerson.pitch = s.pitch or FirstPerson.PITCH_DEFAULT
+    U.wait(20)
+    print(("[tp] %-16s boom %.1f of %.1f"):format(s.label, ThirdPerson.len,
+                                                  ThirdPerson.BOOM))
+    if U.shot(game, ("%s/%s.png"):format(ROOT, s.label)) then
+      shots = shots + 1
+    end
+  end
+
+  -- the slide from 1ST to 3RD: the eye walks out of the head rather than
+  -- cutting, so the halfway frame is a real camera position
+  place("PALLET_TOWN", 13, 14)
+  Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
+  settle()
+  if U.shot(game, ROOT .. "/from_1st.png") then shots = shots + 1 end
+  Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
+  for _ = 1, 300 do
+    if ThirdPerson.out >= 0.5 then break end
+    U.wait(1)
+  end
+  if U.shot(game, ROOT .. "/boom_mid.png") then shots = shots + 1 end
+  U.wait(60)
+  if U.shot(game, ROOT .. "/boom_out.png") then shots = shots + 1 end
+
+  -- ------- the free walk, with the body turning
+  --
+  -- Hold forward with the head yawed off-grid: the player must GLIDE (off
+  -- the 16px grid, which no grid step can do) and the body must end up
+  -- facing its own travel rather than the lens.
+  place("PALLET_TOWN", 13, 14)
+  Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
+  settle()
+  local ow = game.stack:top()
+  local p = ow.player
+  FirstPerson.yaw = 3 * math.pi / 4       -- northeast, deliberately off-grid
+  FirstPerson.pitch = FirstPerson.PITCH_DEFAULT
+  local x0, y0 = p.px, p.py
+  U.hold(game, "up", 90)
+  local moved = math.abs(p.px - x0) + math.abs(p.py - y0)
+  print(("[tp] walk moved %.1f px; off-grid: %s; body faces %s (camera %s)")
+        :format(moved,
+                tostring(p.px % 16 ~= 0 or p.py % 16 ~= 0),
+                tostring(p.facing), tostring(FirstPerson.compassFacing())))
+  if U.shot(game, ROOT .. "/walked.png") then shots = shots + 1 end
+
+  -- strafing: the one case the body facing exists for. Hold RIGHT with the
+  -- head due north and the character must walk east showing its flank.
+  place("PALLET_TOWN", 13, 14)
+  settle()
+  FirstPerson.yaw = math.pi
+  U.hold(game, "right", 40)
+  print(("[tp] strafe: body faces %s, camera looks %s")
+        :format(tostring(p.facing), tostring(FirstPerson.compassFacing())))
+  if U.shot(game, ROOT .. "/strafe.png") then shots = shots + 1 end
+
+  -- ------- the spin, sampled
+  --
+  -- Stand still and turn the camera fast, sampling the frame the player's
+  -- own card actually draws every rendered frame. A standing body is
+  -- pointed along the camera's own yaw, so it must show its back the whole
+  -- way round; anything else is the sideways flick. Sampled through the
+  -- live rig, so this measures the real thing rather than the arithmetic.
+  place("PALLET_TOWN", 13, 14)
+  Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
+  settle()
+  ow = game.stack:top()
+  p = ow.player
+  local seen, frames = {}, 0
+  for _ = 1, 240 do
+    FirstPerson.lookBy(math.rad(7), 0)      -- ~420 deg/s, a hard flick
+    U.wait(1)
+    local f = FirstPerson.playerFacing(p.facing, p.px + 8, p.py + 8)
+    seen[f] = (seen[f] or 0) + 1
+    frames = frames + 1
+  end
+  local report = {}
+  for f, n in pairs(seen) do
+    report[#report + 1] = ("%s x%d"):format(f, n)
+  end
+  table.sort(report)
+  print(("[tp] spin: %d frames, card frames seen: %s")
+        :format(frames, table.concat(report, ", ")))
+
+  print(("[tp] %d shots into %s"):format(shots, ROOT))
+  love.event.quit()
+end