DramaticShapeVoxelMod archival mirror · read-only
6280cdffe7f62578d9788577d097389684f0ece0

Added overworld battles

DramaticShape · 2026-07-28 · parent acc66b51bb

Added overworld battles

Changed files

.modkitignore                 |   3 +
 CHANGELOG.md                  |  47 ++++-
 README.md                     |  48 ++++-
 data/battle_arenas.lua        | 184 +++++++++++++++++
 lib/BattleArena.lua           | 353 ++++++++++++++++++++++++++++++++
 lib/BattleBillboard.lua       | 120 +++++++++++
 lib/BattleCam.lua             | 193 ++++++++++++++++++
 lib/BattleDOF.lua             | 187 +++++++++++++++++
 lib/BattleHud.lua             | 349 ++++++++++++++++++++++++++++++++
 lib/BattlePics.lua            | 157 +++++++++++++++
 lib/BattleScene.lua           | 422 ++++++++++++++++++++++++++++++++++++++
 lib/OverworldBattle.lua       | 683 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 lib/Voxel3D.lua               |  72 ++++++-
 lib/VoxelScene.lua            |  27 ++-
 main.lua                      |  79 +++++++-
 manifest.json                 |   4 +-
 mod.card                      |  17 +-
 tests/arena_pick.lua          | 173 ++++++++++++++++
 tests/battle_shots.lua        | 130 ++++++++++++
 tests/dramatic_shape_test.lua | 402 +++++++++++++++++++++++++++++++++++-
 tools/contact_sheets.py       | 122 +++++++++++
 21 files changed, 3734 insertions(+), 38 deletions(-)

Diff

diff --git a/.modkitignore b/.modkitignore
index c40bb83..eb0f4a9 100644
--- a/.modkitignore
+++ b/.modkitignore
@@ -4,6 +4,8 @@
 # The SDK suite and the probes it grew out of. A shipped test that requires
 # an engine module reads as a private require against the archive
 # (CONTRIBUTING-mods.md "What the PR must contain", 2).
+tests/arena_pick.lua
+tests/battle_shots.lua
 tests/dramatic_shape_test.lua
 tests/voxel_anim_probe.lua
 tests/voxel_door_probe.lua
@@ -19,6 +21,7 @@ tests/voxel_void_probe.lua
 # ROM, read the local cache, and emit assets/voxels/*.lua; the carved models
 # are what a player installs, the carving is not.
 tools/build_voxels.py
+tools/contact_sheets.py
 tools/building_images.py
 tools/building_voxels.py
 tools/voxel-survey.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 526da35..495b14f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,51 @@
 # Changelog
 
-## Unreleased
+## 1.1.0
+
+### Added
+
+- **Battles happen on the map you were standing on.** The battle screen's
+  white field is replaced by the world: the mod finds the nearest patch of
+  open ground, points a placed over-the-shoulder camera at it, and draws the
+  fight over that. New **3D-BTL** row and hotkey `8`, on by default.
+
+  The arena is a 3x6 clearing of cells the player could walk on, with the
+  two mons three cells apart down the middle column and a one-cell apron all
+  round so the camera looks across floor rather than into a wall. Where no
+  map has room for that -- a corridor, a cave, a shop -- the search relaxes
+  to a 1x4 corridor with the apron given up, and where even that will not
+  fit the battle draws exactly as it always did.
+
+  Everything else in the frame is the engine's own. The mon pics, HUDs, HP
+  bars, move animations, faint slides and text box are drawn by BattleState,
+  in its order, at its coordinates -- the GB's own layout, with the player's
+  mon low and left and the enemy's high and right, which is why the camera
+  is placed east of the arena axis rather than the layout being moved to
+  suit the camera. What changes is what is behind them.
+
+  Three things carry the shot. The overworld's cast is culled before the
+  wipe, so it plays over an empty map and no bystander is standing in the
+  arena. The camera drifts on a slow orbit about a point between the two
+  mons, which moves the near ground and the far ground by different amounts
+  -- parallax, not a sliding backdrop. And a depth-of-field pass holds the
+  band of frame the two mons stand in sharp and softens the middle distance
+  and the foreground; both mons are in focus by construction, because they
+  are drawn as the battle screen's own pics after the pass has run.
+
+  **Nobody moves.** The arena is where the CAMERA goes. Nothing here writes
+  a cell, a facing, a flag or a warp, so a trainer's post-battle dialogue is
+  still talking to someone standing in front of them, and the blackout path,
+  sight lines and every script find the player exactly where they left them.
+
+  The two HUD blocks gain the backing the white field used to be. Gen 1
+  draws them as black glyphs straight onto the background with no box round
+  them, and black-on-grass is not readable; the backing is painted inside
+  `drawHUDs`, so it lands in the same target and takes the same zone colour
+  as the HUD it sits under, in both the colorized and flat pipelines.
+
+  Declines cleanly at every step it cannot take: no depth support, no open
+  ground, the row switched off, or a terrain mesh still building all end at
+  the battle screen the engine has always drawn.
 
 ### Changed
 
diff --git a/README.md b/README.md
index de48a41..b85e313 100644
--- a/README.md
+++ b/README.md
@@ -9,10 +9,18 @@ as leaning sprite slabs, a shadow map throws real cast shadows across
 whatever they land on, and an optional tilt-shift pass sells the
 miniature-model look.
 
+And battles fought on that world rather than on a white field. When
+something picks a fight the map's NPCs are culled, the engine's own wipe
+plays over the empty map, and the battle draws over the nearest patch of
+clear ground — shot over the shoulder, the player's mon low and left and
+the enemy high and right, with a slow parallax drift behind them and a
+depth-of-field pass that keeps both of them sharp.
+
 Purely presentational. Nothing here reaches collision, movement, triggers
 or scripts — it changes what the world *looks* like and nothing about what
-it *is*. Battles, menus and cutscenes are untouched; only the free-roam
-overworld draws differently.
+it *is*. The battle arena is where the **camera** goes, not where anybody
+goes: no cell, facing, flag or warp is written, so the player is standing
+exactly where the fight found them when it ends.
 
 ## Controls
 
@@ -25,6 +33,42 @@ menu.
 | `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) |
 | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon |
+| `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field |
+
+**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
+on the world whether or not the free-roam camera is pitched over.
+
+## Where a battle is staged
+
+The mod looks for the nearest clearing shaped like this, where every `x` is
+a cell the player could walk onto — the two mons three cells apart down the
+middle, with a one-cell apron so the camera looks across floor rather than
+into a wall:
+
+```
+x x x
+x O x     O   the enemy's mon
+x x x
+x x x
+x P x     P   your mon
+x x x
+```
+
+Indoors, in a corridor or in a cave there is often no room for that, so the
+search relaxes to the same three-cell gap with the apron given up:
+
+```
+O
+x
+x
+P
+```
+
+If neither will fit — and on a map with no open ground at all, or on a
+machine with no depth-buffer support — the battle draws exactly the way it
+always did. Water counts as open ground while you are surfing and not
+otherwise, and warp tiles never count, so a fight is never framed in a
+doorway.
 
 Two of those keys are taken off the engine: `3` was **TILT** and `5` was
 **GBC FX**. Neither is reachable by key while this mod is enabled, and both
diff --git a/data/battle_arenas.lua b/data/battle_arenas.lua
new file mode 100644
index 0000000..f9b1a26
--- /dev/null
+++ b/data/battle_arenas.lua
@@ -0,0 +1,184 @@
+-- Where each map's battles happen.
+--
+-- One authored spot per area, rather than whichever clearing happens to be
+-- nearest the step the fight started on. A battle on Route 1 should look the
+-- same every time it happens on Route 1 -- and, more importantly, "open
+-- ground" is not the same question as "you can see the two of them": the
+-- battle camera sits low and a long way back, so a hedge, a ledge lip or the
+-- corner of a house anywhere along that sightline hides a Pokemon completely
+-- while every cell it is standing on is perfectly walkable.
+--
+-- Each entry is the arena's north-west corner in map CELLS, and which of
+-- BattleArena.SHAPES it is. Picked by tools/arena_pick (BattleArena.search
+-- with the clearance test on, from the middle of the map) and then looked at,
+-- one screenshot per map -- these are eyeballed answers, not just passing
+-- ones. Grass and flowers around a mon's feet are fine and wanted; anything
+-- that cuts into a body is not.
+--
+-- A map with no entry here falls back to the nearest-clear search at battle
+-- time, so a mod that adds maps, or an entry that goes stale against an
+-- edited map, degrades to the old behaviour rather than to no battle.
+--
+-- The entries below are generated; regenerate with
+--
+--   SHOT_DIR=.scratchpad/arenas \
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
+
+-- `cam = "wide"` on an entry swaps the long default lens for the 44-degree
+-- one (BattleCam.RIGS). Both frame the same composition -- the two mons land
+-- on the same screen anchors either way -- so it is purely a choice about how
+-- much of the place is in shot, at the cost of a smaller pair. Rooms the long
+-- lens cannot stand back from NEED it; anywhere that simply reads better with
+-- more of itself visible may ask for it.
+--
+-- Tall grass is never part of an arena (BattleArena.openCell rejects it), so
+-- a route's spot is always its bare path rather than the field beside it --
+-- grass is knee-high geometry to a Pokemon and this camera is nearly level
+-- with the floor, so tufts on the mon's own tile stand between it and the
+-- lens. Flowers are left alone: they are ankle height and read as ground.
+
+return {
+  -- ------- routes
+  ["ROUTE_1"] = { x = 0, y = 16, shape = "wide" },
+  ["ROUTE_2"] = { x = 1, y = 49, shape = "wide" },
+  ["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
+  ["ROUTE_4"] = { x = 46, y = 7, shape = "wide" },
+  ["ROUTE_5"] = { x = 13, y = 24, shape = "wide" },
+  ["ROUTE_6"] = { x = 5, y = 17, shape = "narrow" },
+  ["ROUTE_7"] = { x = 8, y = 8, shape = "narrow" },
+  ["ROUTE_8"] = { x = 25, y = 7, shape = "wide" },
+  -- the whole route admits six bare wide arenas, all in the west cliff
+  -- corridor; this is the best of them. A flower cluster crosses the far
+  -- mon's hind legs, which the brief allows -- every alternative put a
+  -- terrace through the near mon's waist, which it does not.
+  ["ROUTE_9"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
+  ["ROUTE_10"] = { x = 7, y = 40, shape = "wide" },
+  ["ROUTE_11"] = { x = 9, y = 6, shape = "wide" },
+  ["ROUTE_12"] = { x = 0, y = 73, shape = "wide" },
+  ["ROUTE_13"] = { x = 50, y = 8, shape = "narrow" },
+  ["ROUTE_14"] = { x = 11, y = 25, shape = "wide" },
+  ["ROUTE_15"] = { x = 9, y = 10, shape = "wide" },
+  ["ROUTE_16"] = { x = 6, y = 10, shape = "wide" },
+  ["ROUTE_17"] = { x = 14, y = 70, shape = "wide" },
+  ["ROUTE_18"] = { x = 11, y = 4, shape = "wide" },
+
+  -- ------- buildings and caves
+  --
+  -- Indoors the narrow shape earns its keep: a room with furniture, machinery
+  -- or gravestones in it rarely holds a 3x6 clearing whose whole width is
+  -- also SEEN, and giving up the apron is usually the difference between a
+  -- fight in the open and one behind a console.
+  ["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide" },
+  ["POKEMON_MANSION_2F"] = { x = 15, y = 17, shape = "wide" },
+  ["POKEMON_MANSION_3F"] = { x = 23, y = 2, shape = "narrow", cam = "wide" },
+  ["POKEMON_MANSION_B1F"] = { x = 19, y = 10, shape = "wide" },
+  ["POKEMON_TOWER_2F"] = { x = 4, y = 7, shape = "narrow" },
+  ["POKEMON_TOWER_3F"] = { x = 4, y = 6, shape = "wide" },
+  -- every one of 4F's thirty candidate spots puts a gravestone through a
+  -- mon; the floor below is the same tower and has the room, so the fight
+  -- is shot there
+  ["POKEMON_TOWER_4F"] = { map = "POKEMON_TOWER_3F", x = 4, y = 6,
+                           shape = "wide" },
+  ["POKEMON_TOWER_5F"] = { x = 10, y = 1, shape = "narrow" },
+  ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow" },
+  ["POKEMON_TOWER_7F"] = { x = 9, y = 5, shape = "wide" },
+  ["POWER_PLANT"] = { x = 18, y = 5, shape = "narrow" },
+  ["ROCK_TUNNEL_1F"] = { x = 14, y = 15, shape = "wide" },
+  ["ROCK_TUNNEL_B1F"] = { x = 20, y = 17, shape = "wide" },
+  ["ROCKET_HIDEOUT_B1F"] = { x = 11, y = 6, shape = "narrow" },
+  ["ROCKET_HIDEOUT_B2F"] = { x = 19, y = 7, shape = "narrow" },
+  ["ROCKET_HIDEOUT_B3F"] = { x = 22, y = 11, shape = "narrow" },
+  ["ROCKET_HIDEOUT_B4F"] = { x = 17, y = 3, shape = "narrow" },
+  ["SAFARI_ZONE_CENTER"] = { x = 1, y = 8, shape = "wide" },
+  ["SAFARI_ZONE_EAST"] = { x = 21, y = 8, shape = "wide" },
+  ["SAFARI_ZONE_NORTH"] = { x = 19, y = 14, shape = "wide" },
+  ["SAFARI_ZONE_WEST"] = { x = 18, y = 3, shape = "wide" },
+  ["SEAFOAM_ISLANDS_1F"] = { x = 14, y = 7, shape = "wide" },
+  ["SEAFOAM_ISLANDS_B1F"] = { x = 11, y = 1, shape = "wide" },
+  ["SEAFOAM_ISLANDS_B2F"] = { x = 16, y = 2, shape = "wide" },
+  ["SEAFOAM_ISLANDS_B3F"] = { x = 25, y = 7, shape = "wide" },
+  ["SEAFOAM_ISLANDS_B4F"] = { x = 12, y = 6, shape = "narrow" },
+  -- Silph Co is office floors partitioned into small rooms, so the long lens
+  -- often lands outside the walls it is meant to be looking between; the
+  -- floors that could not be framed any other way ask for the wide one.
+  ["SILPH_CO_2F"] = { x = 16, y = 8, shape = "narrow" },
+  ["SILPH_CO_4F"] = { x = 24, y = 2, shape = "narrow" },
+  ["SILPH_CO_5F"] = { x = 16, y = 7, shape = "wide" },
+  ["SILPH_CO_6F"] = { x = 10, y = 8, shape = "narrow" },
+  ["SILPH_CO_7F"] = { x = 1, y = 2, shape = "wide", cam = "wide" },
+  ["SILPH_CO_8F"] = { x = 8, y = 6, shape = "narrow" },
+  ["SILPH_CO_9F"] = { x = 20, y = 11, shape = "wide", cam = "wide" },
+  ["SS_ANNE_1F_ROOMS"] = { x = 10, y = 1, shape = "narrow", cam = "wide" },
+  -- the ship is all two-cell corridors, so the wide arena shape fits nowhere
+  -- aboard and the long lens always lands outside the hull
+  ["SS_ANNE_2F"] = { x = 36, y = 8, shape = "narrow", cam = "wide" },
+  -- these two decks are byte-identical geometry, so they take the same spot
+  ["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
+  ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
+  ["SS_ANNE_BOW"] = { x = 8, y = 3, shape = "wide" },
+  ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide" },
+  ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide" },
+
+  -- ------- towns and the last interiors
+  ["CERULEAN_CITY"] = { x = 15, y = 16, shape = "wide" },
+  ["GAME_CORNER"] = { x = 8, y = 7, shape = "wide" },
+  -- the lab is ten cells by twelve, so the long lens is always off-map, and
+  -- on it a desk clipped one mon and a pillar the other
+  ["OAKS_LAB"] = { x = 3, y = 2, shape = "narrow", cam = "wide" },
+  -- Saffron's gym is a grid of small walled cells: no wide shape exists
+  -- anywhere in it, and the long lens sits inside a divider
+  ["SAFFRON_GYM"] = { x = 9, y = 7, shape = "narrow", cam = "wide" },
+  ["SILPH_CO_3F"] = { x = 18, y = 11, shape = "wide", cam = "wide" },
+  ["SILPH_CO_10F"] = { x = 1, y = 2, shape = "wide" },
+  ["SILPH_CO_11F"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
+  -- the upper corridor (cols 4-5, rows 1-4) is sealed at runtime by the
+  -- gym's barrier, so arenas there silently fail the fit test
+  ["VERMILION_GYM"] = { x = 4, y = 11, shape = "narrow" },
+  ["VICTORY_ROAD_1F"] = { x = 11, y = 2, shape = "narrow" },
+  ["VIRIDIAN_FOREST"] = { x = 16, y = 34, shape = "narrow" },
+
+  -- ------- the remaining routes
+  ["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" },
+  ["ROUTE_20"] = { x = 53, y = 3, shape = "wide" },
+  ["ROUTE_21"] = { x = 16, y = 4, shape = "wide" },
+  ["ROUTE_22"] = { x = 35, y = 7, shape = "wide" },
+  ["ROUTE_23"] = { x = 4, y = 36, shape = "wide" },
+  ["ROUTE_24"] = { x = 13, y = 15, shape = "wide" },
+  ["ROUTE_25"] = { x = 32, y = 2, shape = "wide", cam = "wide" },
+
+  -- ------- caves, gyms and the Elite Four
+  --
+  -- None of these tilesets has a grass tile at all, so the no-grass rule
+  -- constrained nothing here; what constrains them is furniture, rock
+  -- pillars and how small the rooms are.
+  ["AGATHAS_ROOM"] = { x = 2, y = 1, shape = "narrow", cam = "wide" },
+  ["BRUNOS_ROOM"] = { x = 3, y = 1, shape = "narrow" },
+  ["CELADON_GYM"] = { x = 0, y = 3, shape = "narrow" },
+  ["CERULEAN_CAVE_1F"] = { x = 1, y = 7, shape = "narrow" },
+  -- 2F is a maze of one-cell rock corridors; all 24 of its candidate spots
+  -- hide a mon, so it borrows the floor below -- the same cave
+  ["CERULEAN_CAVE_2F"] = { map = "CERULEAN_CAVE_B1F", x = 2, y = 0,
+                           shape = "wide" },
+  ["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide" },
+  ["CERULEAN_GYM"] = { x = 0, y = 1, shape = "narrow" },
+  ["CHAMPIONS_ROOM"] = { x = 2, y = 2, shape = "narrow", cam = "wide" },
+  ["CINNABAR_GYM"] = { x = 18, y = 10, shape = "narrow" },
+  ["DIGLETTS_CAVE"] = { x = 19, y = 16, shape = "wide" },
+  ["FIGHTING_DOJO"] = { x = 4, y = 1, shape = "narrow" },
+  ["LANCES_ROOM"] = { x = 5, y = 15, shape = "wide" },
+  ["LORELEIS_ROOM"] = { x = 5, y = 2, shape = "narrow" },
+  ["MT_MOON_1F"] = { x = 24, y = 17, shape = "wide" },
+  ["MT_MOON_B1F"] = { x = 5, y = 12, shape = "wide" },
+  ["MT_MOON_B2F"] = { x = 2, y = 16, shape = "wide" },
+
+  -- The three gyms the default rig cannot stand back from. Five blocks is
+  -- further than these rooms are wide, so the eye landed outside the map and
+  -- the border ring -- extruded into a cliff by this mode -- crossed the near
+  -- mon wherever it stood. `cam = "wide"` swaps in the 44-degree lens (see
+  -- BattleCam), which fits inside the room; the mons come out smaller and all
+  -- three became stageable. It is asked for HERE, per map, so every area that
+  -- does not ask keeps the long lens it was framed for.
+  ["FUCHSIA_GYM"] = { x = 7, y = 6, shape = "narrow", cam = "wide" },
+  ["PEWTER_GYM"] = { x = 4, y = 8, shape = "narrow", cam = "wide" },
+  ["VIRIDIAN_GYM"] = { x = 10, y = 8, shape = "narrow", cam = "wide" },
+}
diff --git a/lib/BattleArena.lua b/lib/BattleArena.lua
new file mode 100644
index 0000000..e215b8c
--- /dev/null
+++ b/lib/BattleArena.lua
@@ -0,0 +1,353 @@
+-- Overworld battles: where the fight is staged.
+--
+-- A battle in this mod happens ON THE MAP, so it needs a patch of ground
+-- clear enough to stand two Pokemon on and point a camera down. This module
+-- finds it: the nearest patch of open cells, in the shape below.
+--
+--     x x x
+--     x O x        O   the enemy's mon
+--     x x x
+--     x x x
+--     x P x        P   the player's mon
+--     x x x
+--
+-- Every `x` is an OPEN cell -- one with no obstruction, i.e. one the player
+-- could walk onto. The two mons stand three cells apart down the middle
+-- column, with a one-cell apron all round so the camera looks across floor
+-- rather than into a wall.
+--
+-- When no map has room for that -- a corridor, a cave, a shop floor -- the
+-- search relaxes to the narrow shape, which is the same three-cell gap with
+-- the apron given up:
+--
+--     O
+--     x
+--     x
+--     P
+--
+-- and if even that will not fit, the caller gets nil and the battle draws
+-- the way it always did. A mod that cannot find a stage does not invent
+-- one.
+--
+-- Nothing here MOVES anybody: the arena is where the CAMERA goes and where
+-- the two mons are staged for the shot. The player's own cell, the party,
+-- every script and flag are exactly where the battle left them, which is
+-- what keeps a trainer's post-battle dialogue talking to someone still
+-- standing in front of them.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local BattleArena = {}
+
+-- ------- the authored spot
+--
+-- Every map gets ONE place its battles happen, chosen once and written down
+-- in data/battle_arenas.lua, rather than whatever clearing happens to be
+-- nearest to wherever the fight started. Two reasons.
+--
+-- A fight should look the same every time it happens somewhere. Picking the
+-- nearest patch means Route 1 has a dozen different battle scenes depending
+-- on which step of the grass you were on, some of them behind a tree.
+--
+-- And "open ground" is not the same question as "you can SEE the two of
+-- them". The camera is low and a long way back, so a hedge, a ledge lip or a
+-- building corner anywhere along that line hides a mon completely while the
+-- cells it stands on are perfectly walkable. That is what `clearance` below
+-- measures, and it is what the authored list is chosen against.
+--
+-- A map with no entry falls back to the search, so a mod that adds maps, or
+-- an entry that goes stale, degrades to the old behaviour rather than to no
+-- battle.
+-- An entry may also name ANOTHER MAP to stage on:
+--
+--   ["MT_MOON_B2F"] = { map = "MT_MOON_1F", x = 12, y = 8, shape = "wide" }
+--
+-- because some maps simply have nowhere to put a fight. A cave's lower floor
+-- can be nothing but two-cell-wide corridors between rock walls; a gym is a
+-- room full of furniture. Rather than stage a battle there badly -- both
+-- Pokemon behind a boulder -- the fight is shot on a floor of the SAME cave,
+-- or a floor of the same building, that does have the room for it. It is the
+-- same place, and no worse a fiction than a battle happening on ground the
+-- player is not standing on, which is what every one of these already is.
+local authored = nil
+local overrides = {}
+
+local function authoredFor(mapId)
+  -- `~= nil`, not truthiness: `false` is a meaningful entry here (an
+  -- authored refusal), so it has to reach the caller rather than read as
+  -- "nothing set" and fall through to the data file
+  local forced = overrides[mapId]
+  if forced ~= nil then return forced end
+  if authored == nil then
+    local ok, list = pcall(V.data, "battle_arenas")
+    authored = (ok and type(list) == "table") and list or false
+  end
+  if not authored then return nil end
+  return authored[mapId]
+end
+
+BattleArena.authoredFor = authoredFor
+
+-- Force one map's entry at runtime, ahead of the data file. The authoring
+-- tool's handle: it is how a spot chosen by eye is staged and photographed
+-- before it is written down, and the only way to check a cross-floor entry
+-- without editing the shipped list first. Pass nil to drop it again.
+function BattleArena.setOverride(mapId, entry)
+  overrides[mapId] = entry
+end
+
+-- Cell size in world pixels, the unit every coordinate here is in when it
+-- crosses into the renderer (Map's walk grid is 16px cells).
+local CELL = 16
+
+-- The two shapes, in preference order. `w`/`h` are in cells; `enemy` and
+-- `player` are the offsets, from the shape's north-west corner, of the two
+-- cells a mon stands on.
+BattleArena.SHAPES = {
+  { id = "wide",   w = 3, h = 6, enemy = { 1, 1 }, player = { 1, 4 } },
+  { id = "narrow", w = 1, h = 4, enemy = { 0, 0 }, player = { 0, 3 } },
+}
+
+-- Whether a cell is open ground for the purpose above.
+--
+-- "Open" is the walk test the player themselves answer to, so an arena can
+-- never be laid over a wall, a counter, a tree or a ledge face. Water counts
+-- only for a surfer, which is the one case where the player is standing on
+-- it too -- a sea battle staged on the beach half a route away would read as
+-- a teleport.
+--
+-- Warp cells are excluded on top of that. They are walkable by definition
+-- (they are the doormat), and a fight framed in a doorway both looks wrong
+-- and puts the camera inside the building's geometry.
+--
+-- And TALL GRASS is excluded, which is the surprising one, because grass is
+-- where wild battles come from and standing in it is the obvious place to
+-- have one. It does not survive contact with the camera. Grass is real
+-- geometry in this mode -- a row of tufts about knee height on a Pokemon --
+-- drawn with the same camera-ward bias that lets it overdraw a walking
+-- character's feet in the free-roam world. From a camera nearly level with
+-- the floor that bias stops being feet-deep: the tufts on and around a mon's
+-- own tile stand between it and the lens and eat most of the sprite.
+--
+-- So the arena is laid on bare ground -- the whole footprint, not just the
+-- two cells a mon stands on, because the apron south of the near mon is
+-- exactly the row whose grass would cover it. Grass FURTHER back toward the
+-- camera is fine and stays: it is far enough forward to project low and wide
+-- across the bottom of the frame, where it reads as a field rather than as
+-- something in the way.
+local function openCell(map, cx, cy, surfing)
+  if not map:inBounds(cx, cy) then return false end
+  if map:warpAtCell(cx, cy) then return false end
+  if map:isWarpTileCell(cx, cy) then return false end
+  if map.isGrassCell and map:isGrassCell(cx, cy) then return false end
+  if map:isWalkableCell(cx, cy) then return true end
+  return (surfing and map:isWaterCell(cx, cy)) or false
+end
+
+BattleArena.openCell = openCell
+
+-- The map's open cells as one flat boolean grid, so the rectangle test
+-- below is a lookup rather than a tileset walk per cell. Built once per
+-- search; a battle asks for one.
+local function openGrid(map, surfing)
+  local w, h = map.widthCells, map.heightCells
+  local grid = {}
+  for cy = 0, h - 1 do
+    local row = cy * w
+    for cx = 0, w - 1 do
+      grid[row + cx] = openCell(map, cx, cy, surfing)
+    end
+  end
+  return grid, w, h
+end
+
+local function fits(grid, gw, x, y, w, h)
+  for cy = y, y + h - 1 do
+    local row = cy * gw
+    for cx = x, x + w - 1 do
+      if not grid[row + cx] then return false end
+    end
+  end
+  return true
+end
+
+-- Build the record the renderer reads: the two mons' cells and, in world
+-- pixels, the centre of each and of the pair.
+local function place(shape, x, y)
+  local ex, ey = x + shape.enemy[1], y + shape.enemy[2]
+  local px, py = x + shape.player[1], y + shape.player[2]
+  local arena = {
+    shape = shape.id,
+    x = x, y = y, w = shape.w, h = shape.h,
+    enemyCell = { ex, ey },
+    playerCell = { px, py },
+    -- world-pixel centres of the two cells a mon stands on
+    enemy = { ex * CELL + CELL / 2, ey * CELL + CELL / 2 },
+    player = { px * CELL + CELL / 2, py * CELL + CELL / 2 },
+  }
+  arena.mid = { (arena.enemy[1] + arena.player[1]) / 2,
+                (arena.enemy[2] + arena.player[2]) / 2 }
+  return arena
+end
+
+-- ------- can the two of them actually be SEEN there
+--
+-- The camera sits low and far back on one side, so what hides a mon is not
+-- what is on its own tile -- it is anything TALL between the camera and it.
+-- A tree two cells to the south-east blocks the near mon completely while
+-- every cell of the arena is open ground.
+--
+-- So the line from the eye to each mon is walked in short steps and the
+-- terrain height under each step is compared with how high the line is
+-- there. Three lines per mon -- to its feet, its middle and its head --
+-- because a hedge that clears the head still cuts the body in half.
+--
+-- Grass and flowers are deliberately not obstacles: they stand at ankle
+-- height, they are what a field looks like, and a mon standing in them
+-- reads as standing in a field rather than as being hidden by one.
+BattleArena.SAMPLE_STEP = 4      -- world pixels along the line
+BattleArena.MON_H = 16           -- how tall a mon stands, in world pixels
+BattleArena.CLEAR_EPS = 1.5      -- slack, so a flush kerb is not an obstacle
+
+local function heightAt(map, wx, wz)
+  local cx, cy = math.floor(wx / CELL), math.floor(wz / CELL)
+  if not map:inBounds(cx, cy) then
+    -- off the map the border ring is drawn, and on most outdoor maps that
+    -- ring is trees; treat it as solid so an arena is never framed through it
+    return 32
+  end
+  local ok, h = pcall(V.require("VoxelScene").groundAt, map, cx, cy)
+  return (ok and h) or 0
+end
+
+-- Whether the segment from `eye` to (tx, ty, tz) clears the terrain.
+local function lineClear(map, eye, tx, ty, tz)
+  local dx, dy, dz = tx - eye[1], ty - eye[2], tz - eye[3]
+  local len = math.sqrt(dx * dx + dy * dy + dz * dz)
+  if len <= 1 then return true end
+  local steps = math.ceil(len / BattleArena.SAMPLE_STEP)
+  -- skip the ends: the eye is in open air by construction and the last step
+  -- is the mon's own tile, which it is standing on
+  for i = 1, steps - 1 do
+    local t = i / steps
+    local wx = eye[1] + dx * t
+    local wy = eye[2] + dy * t
+    local wz = eye[3] + dz * t
+    if heightAt(map, wx, wz) > wy + BattleArena.CLEAR_EPS then return false end
+  end
+  return true
+end
+
+-- Whether both mons would be in plain view from the battle camera.
+function BattleArena.clearance(map, arena)
+  local BattleCam = V.require("BattleCam")
+  local ok, rig = pcall(BattleCam.rig, arena, 0)
+  if not (ok and rig and rig.eye) then return true end
+  local eye = rig.eye
+  local H = BattleArena.MON_H
+  for _, mark in ipairs({ arena.player, arena.enemy }) do
+    for _, hy in ipairs({ 1, H * 0.5, H }) do
+      if not lineClear(map, eye, mark[1], hy, mark[2]) then return false end
+    end
+  end
+  return true
+end
+
+-- The nearest arena to (fromX, fromY) -- the player's cell -- or nil when
+-- the map has room for neither shape.
+--
+-- Distance is measured from the player to the arena's MIDPOINT, so "nearest"
+-- means the fight is staged as close to where it was triggered as the ground
+-- allows, rather than merely having a corner nearby.
+--
+-- Both shapes are searched over the whole map before the next one is tried:
+-- a wide arena on the far side of a route still beats a narrow one
+-- underfoot, because the wide one is the shot this mode is framed for.
+function BattleArena.find(map, fromX, fromY, surfing)
+  if not (map and map.widthCells) then return nil end
+
+  -- the authored spot wins outright when the map has one and it still holds
+  local pick = authoredFor(map.id)
+  -- `false` is an authored REFUSAL: a map looked at and found to have nowhere
+  -- a fight can be seen, with no other floor to borrow. Declining is the
+  -- honest answer -- the battle draws on the plain screen -- and it has to be
+  -- said explicitly, because the fallback search below would otherwise go and
+  -- find one of the bad spots that were already rejected by eye.
+  if pick == false then return nil end
+  if pick then
+    local shape = nil
+    for _, s in ipairs(BattleArena.SHAPES) do
+      if s.id == (pick.shape or "wide") then shape = s end
+    end
+    -- an entry may point at another floor of the same cave or building; the
+    -- arena is then measured against THAT map, and carries it
+    local host = map
+    if shape and pick.map and pick.map ~= map.id then
+      local ok, other = pcall(function()
+        local Game = require("src.core.Game")
+        return require("src.world.MapLoader").load(Game.data, pick.map)
+      end)
+      host = (ok and other) or nil
+    end
+    if shape and host then
+      local grid, gw = openGrid(host, surfing)
+      if fits(grid, gw, pick.x, pick.y, shape.w, shape.h) then
+        local arena = place(shape, pick.x, pick.y)
+        arena.map = host
+        -- which camera rig this spot is framed for; nil is the default long
+        -- lens, "close" the short one small rooms need (see BattleCam)
+        arena.cam = pick.cam
+        return arena
+      end
+    end
+  end
+
+  local found = BattleArena.search(map, fromX, fromY, surfing)
+  if found then found.map = map end
+  return found
+end
+
+-- The arena at a given north-west corner, whatever the map says about it.
+-- The authoring tool's manual override: a spot chosen by eye rather than by
+-- the search, so it can be photographed and judged before it is written down.
+function BattleArena.at(x, y, shapeId)
+  for _, shape in ipairs(BattleArena.SHAPES) do
+    if shape.id == (shapeId or "wide") then return place(shape, x, y) end
+  end
+  return nil
+end
+
+-- The nearest arena the map can offer, preferring one the pair can be SEEN
+-- in. Two passes rather than one score: a clear arena on the far side of a
+-- route beats an obstructed one underfoot, because being able to see the
+-- fight is the point, but an obstructed one still beats no battle at all.
+function BattleArena.search(map, fromX, fromY, surfing, wantClear)
+  local grid, gw, gh = openGrid(map, surfing)
+  for _, shape in ipairs(BattleArena.SHAPES) do
+    for _, needClear in ipairs({ true, false }) do
+      local best, bestD = nil, nil
+      for y = 0, gh - shape.h do
+        for x = 0, gw - shape.w do
+          if fits(grid, gw, x, y, shape.w, shape.h) then
+            local mx = x + (shape.w - 1) / 2
+            local my = y + (shape.h - 1) / 2
+            local dx, dy = mx - fromX, my - fromY
+            local d = dx * dx + dy * dy
+            if not bestD or d < bestD then
+              local cand = place(shape, x, y)
+              if not needClear or BattleArena.clearance(map, cand) then
+                best, bestD = cand, d
+              end
+            end
+          end
+        end
+      end
+      if best then return best end
+      if wantClear and needClear then return nil end
+    end
+  end
+  return nil
+end
+
+return BattleArena
diff --git a/lib/BattleBillboard.lua b/lib/BattleBillboard.lua
new file mode 100644
index 0000000..306a2de
--- /dev/null
+++ b/lib/BattleBillboard.lua
@@ -0,0 +1,120 @@
+-- Overworld battles: the two mons, as geometry standing on the map.
+--
+-- Not pics composited over a picture of the world -- quads INSIDE it, drawn
+-- in the same 3D pass as the terrain, from the same camera, through the same
+-- shader. Which means they get everything the world gets and nothing has to
+-- be faked for them: the depth buffer decides what is in front of what, the
+-- sun pass sees them and throws their real silhouettes across the ground,
+-- and their size on screen is whatever standing on that tile at that
+-- distance actually looks like.
+--
+-- One quad, standing upright with its feet on the cell and yawed to face the
+-- camera. Upright rather than leaned back, unlike the free-roam mode's
+-- character cards: those lean because that camera looks DOWN and a standing
+-- card would foreshorten to nothing, and this one looks along the ground
+-- from about a foot above it, where a card standing up is simply correct.
+--
+-- The shader's alpha discard cuts the mon's exact outline out of the quad,
+-- so a Pokemon is its own silhouette against the world with no matte, no
+-- billboard edge and no sorting to get wrong.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local Mat4 = V.require("Mat4")
+local Voxel3D = V.require("Voxel3D")
+
+local BattleBillboard = {}
+
+-- How wide a full-size (7x7 tile) mon stands, in world pixels. One overworld
+-- square, so a Pokemon covers the tile it is on and no more; a species whose
+-- pic is smaller than the full buffer comes out proportionally smaller,
+-- which is how the artwork's own size differences survive the trip.
+BattleBillboard.FULL_W = 16
+BattleBillboard.FULL_PIC = 56     -- the pic size FULL_W refers to
+
+-- A hair of camera-ward bias, so a card standing ON the ground plane wins
+-- the depth test against it instead of z-fighting the tile it is rooted to.
+BattleBillboard.PULL = 1.5
+
+local quad = nil                  -- nil = untried, false = unavailable
+
+-- The unit card: x in -0.5..0.5, y in 0..1, z = 0, UV over the whole
+-- texture. Feet on the model origin, so the model matrix only has to say
+-- where the mon is standing and how big it is.
+local function unitQuad()
+  if quad ~= nil then return quad or nil end
+  local verts = {
+    { -0.5, 0, 0, 0, 1, 1 },
+    { 0.5, 0, 0, 1, 1, 1 },
+    { 0.5, 1, 0, 1, 0, 1 },
+    { -0.5, 1, 0, 0, 0, 1 },
+  }
+  local indices = {}
+  Voxel3D.pushQuad(indices, 0)
+  local mesh = Voxel3D.newMesh(verts, indices)
+  quad = mesh or false
+  return quad or nil
+end
+
+BattleBillboard.mesh = unitQuad
+
+-- The yaw that turns the card's face toward the eye. The quad's normal is
+-- +Z before rotation, so this is just the bearing from the mon to the
+-- camera -- flattened to the horizontal, because a card that also tipped to
+-- face a camera above it would lift its feet off the floor.
+function BattleBillboard.yawToward(x, z, eye)
+  if not eye then return 0 end
+  return math.atan2(eye[1] - x, eye[3] - z)
+end
+
+-- Stand a `w` x `h` card with its feet centred on world (x, y, z).
+function BattleBillboard.matrix(x, y, z, w, h, yaw)
+  return Mat4.mul(Mat4.mul(Mat4.translate(x, y, z), Mat4.rotateY(yaw)),
+                  Mat4.scale(w, h, 1))
+end
+
+-- The world size a pic of `pw` x `ph` texture pixels stands at.
+function BattleBillboard.sizeFor(pw, ph)
+  if not (pw and ph and pw > 0 and ph > 0) then return 0, 0 end
+  local scale = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC
+  return pw * scale, ph * scale
+end
+
+-- Draw one mon. `tex` is the pic already rendered to a texture (see
+-- OverworldBattle, which lets the engine's own battler draw produce it, so
+-- every faint slide, blink and squish comes along), `grow` the send-out
+-- animation's scale or nil.
+function BattleBillboard.draw(tex, x, y, z, grow)
+  local mesh = unitQuad()
+  if not (mesh and tex) then return false end
+  local pw, ph = tex:getDimensions()
+  local w, h = BattleBillboard.sizeFor(pw, ph)
+  if grow then w, h = w * grow, h * grow end
+  if w <= 0 or h <= 0 then return false end
+  local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
+  Voxel3D.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw),
+               BattleBillboard.PULL)
+  return true
+end
+
+-- The same card, as the SUN sees it: no camera-ward pull (that is a trick
+-- for the view's own depth buffer and would drag the shadow off its owner)
+-- and no draw call of its own, because the shadow pass has its own.
+function BattleBillboard.caster(shadowMap, tex, x, y, z, grow)
+  local mesh = unitQuad()
+  if not (mesh and tex) then return false end
+  local pw, ph = tex:getDimensions()
+  local w, h = BattleBillboard.sizeFor(pw, ph)
+  if grow then w, h = w * grow, h * grow end
+  if w <= 0 or h <= 0 then return false end
+  local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
+  shadowMap.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw))
+  return true
+end
+
+function BattleBillboard.invalidate()
+  quad = nil
+end
+
+return BattleBillboard
diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua
new file mode 100644
index 0000000..07bc78e
--- /dev/null
+++ b/lib/BattleCam.lua
@@ -0,0 +1,193 @@
+-- Overworld battles: the over-the-shoulder camera and its parallax drift.
+--
+-- The two mons are PINNED to their cells: each pic is drawn wherever its
+-- patch of ground projects to, not at a fixed screen slot. So the camera is
+-- not decoration -- it is the thing that decides where the fight appears,
+-- and it has to put those two patches of ground exactly where the battle
+-- screen wants its two pics:
+--
+--     the player's mon   (26, 96)    back pic, feet on the text box, well left
+--     the enemy's mon   (124, 56)    front pic, bottom of the 7x7 slot
+--
+-- Four screen coordinates, so four equations. The rig below is the solution:
+-- SIDE / BACK / HEIGHT place the eye relative to the arena's midpoint, LOOK
+-- aims it, and FRAME_H sets the lens, and together they land both marks
+-- within a thousandth of a pixel of the targets. They are not hand-picked
+-- numbers that looked about right -- they came out of a solver, and the
+-- suite reprojects them so a future edit either still lands or says so.
+--
+-- East is what decides which mon is on which side. The arena axis runs north
+-- (the enemy) to south (the player's mon), and a camera east of that axis
+-- sees the near end swing LEFT and the far end RIGHT -- the layout arrived
+-- at by standing in the right place rather than by mirroring anything.
+--
+-- ------- and two more equations, from the pixels
+--
+-- The pics are pixel art and their size on screen is not something the mod
+-- gets to choose: 56 pixels for a front pic, 64 for a back one. And a mon has
+-- to stand in ONE OVERWORLD SQUARE, or it towers over the houses and gives
+-- away that the world behind it is a picture. Together those say the square
+-- each mon stands on must project to about the width of its own pic, which is
+-- two more equations for the same six unknowns -- and they are what set the
+-- distance.
+--
+-- The answer is a LONG LENS FROM A LOW STANCE: twelve degrees above the
+-- floor, twelve degrees wide, from five blocks back. Not a stylistic choice
+-- -- it is what a 56-pixel sprite standing on a 16-pixel tile forces on a
+-- 160-pixel screen. Roughly three tiles fit across the frame, so the camera
+-- has to be far away and zoomed in rather than near and wide. That is the
+-- DEFAULT rig, and every map that can take it gets it.
+--
+-- ------- the exception: rooms too small to stand back from
+--
+-- Five blocks back is further than some rooms are wide. A gym is about ten
+-- cells across, so on one the eye lands OUTSIDE the map, where the border
+-- ring the engine draws round every map -- extruded into a cliff by this mode
+-- -- crosses the near Pokemon wherever it stands. Three gyms could not be
+-- staged anywhere at all for that reason.
+--
+-- So there is a second rig, and an arena asks for it by name (cam = "wide"
+-- in data/battle_arenas.lua). It comes in to about four cells with the lens
+-- opened up to match: an ordinary 44-degree shot that fits inside the room.
+-- The mons render smaller for it -- a bit over half a tile rather than a
+-- whole one -- which is the price. Both rigs are solved against the SAME four
+-- anchors, so the composition is identical either way; only the lens and the
+-- distance differ, which is what makes it safe to pick per map.
+--
+-- Rooms too small for the long lens are the reason it exists, but it is not
+-- only for them: an area that simply reads better with more of itself in
+-- shot can ask for it too.
+--
+-- Purely presentational, like everything else in this mod: the camera looks
+-- at the map, and nothing it does reaches collision, movement or scripts.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local BattleCam = {}
+
+-- ------- the rig, in world pixels (a map cell is 16, a block 32)
+--
+-- Solved against the four anchors and two spans above, with the two mons 48
+-- world pixels (three cells) apart -- BattleArena.SHAPES is where that gap is
+-- set, and changing it invalidates these.
+
+-- `frameH` is how much world the frame is tall enough to hold at the aim
+-- distance, which together with that distance is the lens.
+-- Named for the LENS, because that is what an author is choosing between
+-- when they look at a shot and decide it wants more room in it.
+BattleCam.RIGS = {
+  -- the default: a long 11.5-degree lens from five blocks back, which is
+  -- what makes one tile big enough to stand a 56-pixel mon on
+  tele = {
+    side = 78.79, back = 144.96, height = 37.88,
+    lookX = -0.26, lookY = 0.34, frameH = 34.11,
+  },
+  -- 44 degrees from four cells: fits inside a room the long lens cannot
+  -- stand back from, and shows more of anywhere else, at the cost of a
+  -- smaller pair
+  wide = {
+    side = 41.98, back = 41.16, height = 28.48,
+    lookX = -3.24, lookY = -1.35, frameH = 55.62,
+  },
+}
+
+BattleCam.DEFAULT_RIG = "tele"
+
+-- The rig an arena asks for, falling back to the default for anything that
+-- does not ask (and for a name that is not one of the two).
+function BattleCam.rigFor(arena)
+  local want = arena and arena.cam
+  return BattleCam.RIGS[want] or BattleCam.RIGS[BattleCam.DEFAULT_RIG]
+end
+
+-- ------- the drift
+--
+-- A slow orbit about the arena's vertical axis. Rotating about a point
+-- BETWEEN the two mons is what makes it parallax rather than a pan: the mons
+-- are pinned to the ground, so the near one slides one way across the frame
+-- and the far one slides the OTHER, by the amount their difference in
+-- distance implies. Over a full swing that is about eight pixels of relative
+-- movement -- plainly visible as depth, far too slow to fight the fight.
+-- The angle is small because the lens is long: two degrees of orbit is seven
+-- pixels of travel through an eleven-degree field of view.
+--
+-- Under it, a much smaller breath in and out along the same line, on an
+-- unrelated period, so the pair never returns to the same pose on any cycle
+-- a battle is long enough to show. A DOLLY rather than a pan of the aim:
+-- moving the aim point would slide both mons the same way, which with pinned
+-- pics is just the whole picture walking sideways. Changing the DISTANCE
+-- moves them apart and back together about the frame's centre, which is the
+-- same depth cue the orbit gives, from the other axis.
+BattleCam.PAN_YAW = math.rad(2)   -- half-angle of the orbit
+BattleCam.PAN_PERIOD = 26         -- seconds for one there-and-back
+BattleCam.PAN_DOLLY = 0.02        -- how far the eye breathes, as a fraction
+BattleCam.DOLLY_PERIOD = 37
+
+BattleCam.t = 0
+
+function BattleCam.reset()
+  BattleCam.t = 0
+end
+
+-- Real frame time, like every other presentational tween in this mod: a
+-- fast-forwarded battle must not spin the camera.
+function BattleCam.update(dt)
+  BattleCam.t = BattleCam.t + (dt or 0)
+  -- keep the phase small forever rather than letting a long session lose
+  -- float precision in the sines below
+  local wrap = BattleCam.PAN_PERIOD * BattleCam.DOLLY_PERIOD
+  if BattleCam.t > wrap then BattleCam.t = BattleCam.t - wrap end
+end
+
+local function phase(t, period)
+  return math.sin(2 * math.pi * t / period)
+end
+
+-- The camera for `arena` this instant: the record Voxel3D.camera takes, plus
+-- the pitch the pull and the sun frustum want (measured from straight down,
+-- the same convention Voxel.angle uses).
+--
+-- `fov` here frames the GB's 160x144. A caller rendering at window
+-- resolution widens it for the extra picture around that frame -- see
+-- BattleScene.letterboxFov, which is what keeps the pins exact at any window
+-- size.
+--
+-- `groundY` is the height of the arena floor, so a fight staged on a ledge
+-- or a raised walkway is shot from above THAT rather than from inside it.
+function BattleCam.rig(arena, groundY)
+  groundY = groundY or 0
+  local R = BattleCam.rigFor(arena)
+  local mx, mz = arena.mid[1], arena.mid[2]
+
+  local yaw = BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD)
+  local c, s = math.cos(yaw), math.sin(yaw)
+  -- the breath scales the whole offset, height included, so the eye moves
+  -- along its own line to the arena and the pitch of the shot never changes
+  local k = 1 + BattleCam.PAN_DOLLY
+              * phase(BattleCam.t, BattleCam.DOLLY_PERIOD)
+  local dx = (R.side * c - R.back * s) * k
+  local dz = (R.side * s + R.back * c) * k
+
+  local eye = { mx + dx, groundY + R.height * k, mz + dz }
+  local focus = { mx + R.lookX, groundY + R.lookY, mz }
+
+  local ex = eye[1] - focus[1]
+  local ey = eye[2] - focus[2]
+  local ez = eye[3] - focus[3]
+  local dist = math.max(1, math.sqrt(ex * ex + ey * ey + ez * ez))
+  local horiz = math.sqrt(ex * ex + ez * ez)
+
+  return {
+    eye = eye,
+    focus = focus,
+    fov = 2 * math.atan((R.frameH / 2) / dist),
+    -- the world curve is a free-roam flourish that bends the horizon away
+    -- from the player; a fixed camera on a staged shot has no player to bend
+    -- around, and the bend would tip the arena floor out from under the mons
+    -- the pics are pinned to
+    curve = 0,
+  }, math.atan2(horiz, math.max(1e-3, ey))
+end
+
+return BattleCam
diff --git a/lib/BattleDOF.lua b/lib/BattleDOF.lua
new file mode 100644
index 0000000..1b58ca4
--- /dev/null
+++ b/lib/BattleDOF.lua
@@ -0,0 +1,187 @@
+-- Overworld battles: the depth-of-field pass over the arena.
+--
+-- A photographic lens focused on two subjects three cells apart holds a
+-- narrow slab of the world sharp and loses everything in front of and
+-- behind it. On this shot that slab is a BAND ACROSS THE FRAME, because the
+-- camera is fixed and looking down at a floor: distance from the lens runs
+-- monotonically up the picture, so screen height IS depth, and a band of
+-- rows is a slab of world. The floor the mons stand on stays sharp, the
+-- middle distance and the horizon soften, and the foreground the camera is
+-- leaning over softens the other way.
+--
+-- BOTH MONS ARE IN FOCUS, and not by tuning: they are drawn as the battle
+-- screen's own pics AFTER this runs, so they are never blurred at all. This
+-- pass only ever touches the world behind them -- which is exactly the
+-- separation a real shot of two subjects at the same distance would have.
+--
+-- Two separable gaussian passes over a 160x144 image: two full-frame draws
+-- of 23,040 pixels, which is nothing, and it buys the one cue that stops a
+-- 3D backdrop reading as wallpaper.
+
+local BattleDOF = {}
+
+-- Switched off for now. The pass is kept whole -- band maths, shader,
+-- canvases -- because the reason to have it has not gone away: it is the one
+-- cue that separates the pair from the ground behind them. It is off because
+-- the mons are geometry in the scene now rather than pics over it, and a blur
+-- that softens their own tile softens THEM, which the pinned-pic version
+-- never had to answer for. Turning it back on means solving that first.
+BattleDOF.ENABLED = false
+
+-- Fallbacks for the band, in canvas uv. The caller normally measures it off
+-- the two ground marks -- the whole point is that the slab in focus is the
+-- one the mons are standing in -- and these are what a caller that cannot
+-- gets instead.
+BattleDOF.FOCUS_Y = 0.52
+BattleDOF.BAND = 0.16
+BattleDOF.RANGE = 0.32
+
+-- How much floor either side of the two marks stays sharp, as a fraction of
+-- the gap between them: a mon is taller than the patch it stands on, and the
+-- ground just in front of and behind it belongs to the same slab.
+BattleDOF.BAND_MARGIN = 0.55
+
+-- How far past the band it takes to reach full blur, in the same units as
+-- the band itself.
+BattleDOF.RANGE_SCALE = 2.0
+
+-- Tap spacing at full blur, as a fraction of the canvas height, so the blur
+-- is the same depth of field in a window and fullscreen. The gaussian's
+-- reach is four taps and the two passes compound, so a little goes a long
+-- way.
+BattleDOF.SPACING = 0.0095
+
+-- A touch of saturation on the way out, the same trick the tilt-shift pass
+-- uses: a blurred background reads as further away when it is also a
+-- little richer than the sharp subject in front of it.
+BattleDOF.SATURATION = 1.12
+
+local SHADER = [[
+  uniform vec2 dir;        // one texel step along the axis being blurred
+  uniform float focusY;
+  uniform float band;
+  uniform float range;
+  uniform float spacing;
+  uniform float boost;     // 0 = plain blur pass, 1 = final pass (colour pop)
+  uniform float saturation;
+  vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
+    float d = abs(tc.y - focusY) - band;
+    float s = clamp(d / range, 0.0, 1.0);
+    s = s * s;             // ease in, so the band edge has no visible seam
+    vec2 o = dir * (s * spacing);
+    vec4 sum = Texel(tex, tc) * 0.2270270270;
+    sum += (Texel(tex, tc + o) + Texel(tex, tc - o)) * 0.1945945946;
+    sum += (Texel(tex, tc + 2.0 * o) + Texel(tex, tc - 2.0 * o)) * 0.1216216216;
+    sum += (Texel(tex, tc + 3.0 * o) + Texel(tex, tc - 3.0 * o)) * 0.0540540541;
+    sum += (Texel(tex, tc + 4.0 * o) + Texel(tex, tc - 4.0 * o)) * 0.0162162162;
+    if (boost > 0.5) {
+      float luma = dot(sum.rgb, vec3(0.299, 0.587, 0.114));
+      sum.rgb = mix(vec3(luma), sum.rgb, saturation);
+    }
+    return sum * color;
+  }
+]]
+
+local shader = nil            -- nil = untried, false = unavailable
+local ping, pong, cw, ch = nil, nil, 0, 0
+
+local function getShader()
+  if shader == nil then
+    local ok, sh = pcall(love.graphics.newShader, SHADER)
+    shader = (ok and sh) or false
+  end
+  return shader or nil
+end
+
+-- Its own pair of canvases rather than the tilt-shift pass's: those are
+-- sized to the window and this is sized to the GB frame, and sharing them
+-- would reallocate both every time a battle started or ended.
+local function getCanvases(w, h)
+  if not ping or cw ~= w or ch ~= h then
+    local ok, a = pcall(love.graphics.newCanvas, w, h)
+    if not ok then return nil end
+    local okB, b = pcall(love.graphics.newCanvas, w, h)
+    if not okB then return nil end
+    -- the gaussian's fractional tap offsets need linear filtering
+    a:setFilter("linear", "linear")
+    b:setFilter("linear", "linear")
+    ping, pong, cw, ch = a, b, w, h
+  end
+  return ping, pong
+end
+
+-- The sharp band for a shot whose two ground marks land at canvas rows
+-- `y1` and `y2`, as (focusY, band, range) in uv. This is the depth of field
+-- proper: the band is the slab of world the two mons occupy, and everything
+-- nearer or further softens away from it.
+function BattleDOF.bandFor(y1, y2, h)
+  if not (y1 and y2 and h and h > 0) then
+    return BattleDOF.FOCUS_Y, BattleDOF.BAND, BattleDOF.RANGE
+  end
+  local mid = (y1 + y2) / 2 / h
+  local half = math.abs(y1 - y2) / 2 / h
+  local band = half * (1 + BattleDOF.BAND_MARGIN)
+  return math.min(1, math.max(0, mid)),
+         band,
+         math.max(1e-3, band * BattleDOF.RANGE_SCALE)
+end
+
+-- Run the pass over `canvas` and return the result, or the input unchanged
+-- when it cannot run (headless, no shader support) -- so the caller always
+-- has something to composite. `focusY`, `band` and `range` are in canvas uv;
+-- omit them for the fixed fallback band.
+function BattleDOF.apply(canvas, focusY, band, range)
+  if not (canvas and BattleDOF.ENABLED) then return canvas end
+  local sh = getShader()
+  if not sh then return canvas end
+  local w, h = canvas:getDimensions()
+  local a, b = getCanvases(w, h)
+  if not a then return canvas end
+  focusY = focusY or BattleDOF.FOCUS_Y
+  band = band or BattleDOF.BAND
+  range = range or BattleDOF.RANGE
+
+  local prevBlend, prevAlpha = love.graphics.getBlendMode()
+  local prevCanvas = love.graphics.getCanvas()
+
+  -- the scene canvas filters nearest for its 1:1 blit; the taps need linear,
+  -- restored below so the composite sees what it expects
+  canvas:setFilter("linear", "linear")
+  love.graphics.setShader(sh)
+  love.graphics.setColor(1, 1, 1, 1)
+  -- replace, not alpha-blend: these are image-processing copies
+  love.graphics.setBlendMode("replace", "premultiplied")
+  pcall(sh.send, sh, "focusY", focusY)
+  pcall(sh.send, sh, "band", band)
+  pcall(sh.send, sh, "range", range)
+  pcall(sh.send, sh, "spacing", math.max(0.75, h * BattleDOF.SPACING))
+  pcall(sh.send, sh, "saturation", BattleDOF.SATURATION)
+
+  local ok = pcall(function()
+    love.graphics.setCanvas(a)
+    pcall(sh.send, sh, "dir", { 1 / w, 0 })
+    pcall(sh.send, sh, "boost", 0)
+    love.graphics.draw(canvas)
+    love.graphics.setCanvas(b)
+    pcall(sh.send, sh, "dir", { 0, 1 / h })
+    pcall(sh.send, sh, "boost", 1)
+    love.graphics.draw(a)
+  end)
+
+  if prevCanvas then
+    love.graphics.setCanvas(prevCanvas)
+  else
+    love.graphics.setCanvas()
+  end
+  love.graphics.setShader()
+  love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
+  canvas:setFilter("nearest", "nearest")
+  return ok and b or canvas
+end
+
+-- Drop the GPU objects (window resize, hot reload).
+function BattleDOF.invalidate()
+  ping, pong, cw, ch = nil, nil, 0, 0
+end
+
+return BattleDOF
diff --git a/lib/BattleHud.lua b/lib/BattleHud.lua
new file mode 100644
index 0000000..f88f264
--- /dev/null
+++ b/lib/BattleHud.lua
@@ -0,0 +1,349 @@
+-- Overworld battles: the HUD's footing on a world that is not white.
+--
+-- Gen 1 draws its battle HUDs as black glyphs and bar tiles straight onto
+-- the white field, with no box around them -- the field IS the backing. Take
+-- the field away and put a route underneath and the name, the level and the
+-- HP numbers are black on grass, which is not readable.
+--
+-- So each HUD block gets a panel: the world behind it, blurred to frosted
+-- glass and laid back down translucent, with a tint that pushes it away from
+-- whatever colour the text is about to be. Frosted rather than opaque
+-- because the point of the mode is that you can see where you are standing,
+-- and an opaque slab in the corner of the frame is the white field back
+-- again by another name.
+--
+-- And the text flips. A panel over a sunlit meadow is bright and wants black
+-- glyphs; the same panel over a cave floor or a dark roof is not, and wants
+-- white ones. So the panel's average brightness is measured and the glyphs
+-- follow it, with hysteresis so a slow camera drift across the threshold
+-- cannot strobe them.
+--
+-- The measurement is a one-pixel readback, which is a GPU stall, so it runs
+-- a few times a second rather than every frame. The camera drifts at about
+-- a pixel a second; brightness cannot outrun that.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local BattleHud = {}
+
+-- How solid the frost is over the world behind it, and how far the tint
+-- pushes it toward the far end from the text.
+--
+-- Both deliberately light. The panel is there to make glyphs legible, not to
+-- put a slab in the corner of the frame: at these values the sharp world
+-- still reads through it and the blur registers as a pane of glass rather
+-- than as a second background.
+BattleHud.FROST = 0.55
+BattleHud.TINT = 0.26
+
+-- The luminance the glyphs flip at, with a dead band so a drift across it
+-- settles rather than strobes.
+BattleHud.DARK_ENTER = 0.44   -- below this, the panel is dark: white glyphs
+BattleHud.DARK_LEAVE = 0.56   -- above this, back to black ones
+
+-- Frames between brightness readbacks.
+BattleHud.SAMPLE_EVERY = 12
+
+-- The frost buffer's height; width follows the source's aspect. Small on
+-- purpose: the downscale is most of the blur, and what is read back for the
+-- brightness is one pixel of it.
+BattleHud.FROST_H = 72
+
+local frost, frostW, frostH = nil, 0, 0
+local blurA, blurB = nil, nil
+local probe = nil
+local frame = 0
+local luma = {}      -- panel key -> { value, dark, at }
+
+local SHADER = [[
+  uniform vec2 dir;
+  vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
+    vec4 sum = Texel(tex, tc) * 0.2270270270;
+    sum += (Texel(tex, tc + dir) + Texel(tex, tc - dir)) * 0.1945945946;
+    sum += (Texel(tex, tc + 2.0 * dir) + Texel(tex, tc - 2.0 * dir)) * 0.1216216216;
+    sum += (Texel(tex, tc + 3.0 * dir) + Texel(tex, tc - 3.0 * dir)) * 0.0540540541;
+    sum += (Texel(tex, tc + 4.0 * dir) + Texel(tex, tc - 4.0 * dir)) * 0.0162162162;
+    return sum * color;
+  }
+]]
+
+local shader = nil            -- nil = untried, false = unavailable
+
+local function getShader()
+  if shader == nil then
+    local ok, sh = pcall(love.graphics.newShader, SHADER)
+    shader = (ok and sh) or false
+  end
+  return shader or nil
+end
+
+local function canvasOf(w, h, filter)
+  local ok, c = pcall(love.graphics.newCanvas, w, h)
+  if not ok then return nil end
+  c:setFilter(filter or "linear", filter or "linear")
+  return c
+end
+
+-- Build (or rebuild) the frosted copy of `src` for this frame.
+--
+-- Two steps, because one is not enough: the downscale to a 72-row buffer
+-- averages the world down to something that no longer reads as terrain, and
+-- the separable gaussian over that turns the remaining structure into
+-- frosted glass rather than a mosaic of the tiles it came from.
+function BattleHud.build(src)
+  if not src then return nil end
+  local blur = getShader()
+  local sw, sh = src:getDimensions()
+  if sw <= 0 or sh <= 0 then return nil end
+  local h = BattleHud.FROST_H
+  local w = math.max(1, math.floor(sw * h / sh + 0.5))
+  if not frost or frostW ~= w or frostH ~= h then
+    frost = canvasOf(w, h)
+    blurA = canvasOf(w, h)
+    blurB = canvasOf(w, h)
+    probe = probe or canvasOf(1, 1)
+    if not (frost and blurA and blurB) then
+      frost, blurA, blurB, frostW, frostH = nil, nil, nil, 0, 0
+      return nil
+    end
+    frostW, frostH = w, h
+  end
+
+  local prevCanvas = love.graphics.getCanvas()
+  local prevBlend, prevAlpha = love.graphics.getBlendMode()
+  local prevFilter = { src:getFilter() }
+  src:setFilter("linear", "linear")
+  love.graphics.setColor(1, 1, 1, 1)
+  love.graphics.setBlendMode("replace", "premultiplied")
+
+  local ok = pcall(function()
+    love.graphics.setCanvas(frost)
+    love.graphics.draw(src, 0, 0, 0, w / sw, h / sh)
+    if blur then
+      love.graphics.setShader(blur)
+      love.graphics.setCanvas(blurA)
+      pcall(blur.send, blur, "dir", { 2.5 / w, 0 })
+      love.graphics.draw(frost)
+      love.graphics.setCanvas(blurB)
+      pcall(blur.send, blur, "dir", { 0, 2.5 / h })
+      love.graphics.draw(blurA)
+      love.graphics.setShader()
+      frost, blurB = blurB, frost   -- the blurred one is the frost now
+    end
+  end)
+
+  love.graphics.setShader()
+  if prevCanvas then
+    love.graphics.setCanvas(prevCanvas)
+  else
+    love.graphics.setCanvas()
+  end
+  love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
+  src:setFilter(prevFilter[1] or "nearest", prevFilter[2] or "nearest")
+  frame = frame + 1
+  return ok and frost or nil
+end
+
+function BattleHud.frame()
+  return frame
+end
+
+-- Average luminance of the frost under `key`'s rect, in frost-canvas pixels.
+--
+-- Averaged by letting the GPU do it: the rect is drawn into a one-pixel
+-- canvas, which IS the mean, and that one pixel is read back. Cached for
+-- SAMPLE_EVERY frames because the readback synchronises the pipeline and
+-- nothing it measures moves faster than that.
+local function sampleLuma(key, fx, fy, fw, fh)
+  local hit = luma[key]
+  if hit and (frame - hit.at) < BattleHud.SAMPLE_EVERY then return hit.value end
+  if not (frost and probe and frostW > 0) then return hit and hit.value end
+  if fw <= 0 or fh <= 0 then return hit and hit.value end
+
+  local prevCanvas = love.graphics.getCanvas()
+  local prevBlend, prevAlpha = love.graphics.getBlendMode()
+  local value = hit and hit.value or 1
+  local ok = pcall(function()
+    love.graphics.setCanvas(probe)
+    love.graphics.setBlendMode("replace", "premultiplied")
+    love.graphics.setColor(1, 1, 1, 1)
+    local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
+    love.graphics.draw(frost, quad, 0, 0, 0, 1 / fw, 1 / fh)
+    love.graphics.setCanvas()
+    local data = probe:newImageData()
+    local r, g, b = data:getPixel(0, 0)
+    if data.release then pcall(data.release, data) end
+    value = 0.299 * r + 0.587 * g + 0.114 * b
+  end)
+
+  if prevCanvas then
+    love.graphics.setCanvas(prevCanvas)
+  else
+    love.graphics.setCanvas()
+  end
+  love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
+  if not ok then return hit and hit.value end
+
+  luma[key] = { value = value, at = frame }
+  return value
+end
+
+-- Map a GB-frame rect onto the frost canvas, given where the letterbox sits
+-- in the source the frost was built from.
+local function frostRect(rect, box)
+  local kx = frostW / box.pw
+  local ky = frostH / box.ph
+  local fx = (box.lx + rect[1] * box.scale) * kx
+  local fy = (box.ly + rect[2] * box.scale) * ky
+  local fw = rect[3] * box.scale * kx
+  local fh = rect[4] * box.scale * ky
+  return fx, fy, math.max(1, fw), math.max(1, fh)
+end
+
+-- ------- the verdict
+--
+-- ONE answer for the whole frame, not one per panel. Both HUDs draw in a
+-- single pass and there is only one glyph colour to be had out of it -- and
+-- a frame with a black-lettered HUD in one corner and a white-lettered one
+-- in the other would read as a bug rather than as adaptation. The DARKER
+-- panel decides, because it is the one that cannot afford to be wrong, and
+-- the tint below then commits both panels to that reading.
+local wasDark = false
+
+function BattleHud.verdict(rects, box)
+  if not (frost and box and box.scale and box.scale > 0) then return false end
+  local darkest = nil
+  for key, rect in pairs(rects) do
+    local fx, fy, fw, fh = frostRect(rect, box)
+    local v = sampleLuma(key, fx, fy, fw, fh)
+    if v and (not darkest or v < darkest) then darkest = v end
+  end
+  if not darkest then return wasDark end
+  -- hysteresis: it takes a clear move past the far threshold to flip back,
+  -- so a camera drifting across the boundary settles instead of strobing
+  if wasDark then
+    wasDark = darkest < BattleHud.DARK_LEAVE
+  else
+    wasDark = darkest < BattleHud.DARK_ENTER
+  end
+  return wasDark
+end
+
+-- Draw one HUD panel into the current target, in GB coordinates.
+--
+-- The tint always pushes AWAY from the glyph colour that is about to be
+-- used, so the contrast is guaranteed rather than hoped for: a dark panel
+-- gets darker under white text, a bright one brighter under black text.
+function BattleHud.panel(rect, box, dark)
+  if not (frost and box and box.scale and box.scale > 0) then return false end
+  local fx, fy, fw, fh = frostRect(rect, box)
+  local ok = pcall(function()
+    local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
+    love.graphics.setColor(1, 1, 1, BattleHud.FROST)
+    love.graphics.draw(frost, quad, rect[1], rect[2], 0,
+                       rect[3] / fw, rect[4] / fh)
+    local shade = dark and 0 or 1
+    love.graphics.setColor(shade, shade, shade, BattleHud.TINT)
+    love.graphics.rectangle("fill", rect[1], rect[2], rect[3], rect[4])
+    love.graphics.setColor(1, 1, 1, 1)
+  end)
+  return ok
+end
+
+-- ------- flipping the glyphs
+--
+-- Over a dark panel the HUD's black text has to go white, and it cannot be
+-- done by setting a draw colour: LOVE MULTIPLIES by it, and a black glyph
+-- times white is still black. The colour channel has to be REPLACED.
+--
+-- So the HUD is drawn into a scratch layer and that layer is composited back
+-- through a shader that whitens whatever is nearly black and leaves the rest
+-- alone. "Nearly black" is the text, the tick marks and the bar's outline --
+-- everything the HUD draws as ink -- while the HP bar's own greens and reds
+-- are well clear of the threshold and come through untouched.
+--
+-- Composited back into whatever the caller had bound, which is what makes it
+-- work in both pipelines without knowing which one it is in: in the colorized
+-- one that target is the grayscale BG canvas, where white IS shade 0 and the
+-- zone pass then colours the flipped glyphs like every other lightest-shade
+-- surface; in the flat fallback it is the screen, where white is white.
+local INK = 0.35   -- luminance at or under which a pixel counts as ink
+
+local FLIP = [[
+  uniform float ink;
+  vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
+    vec4 p = Texel(tex, tc);
+    float luma = dot(p.rgb, vec3(0.299, 0.587, 0.114));
+    if (p.a > 0.0 && luma <= ink * p.a) p.rgb = vec3(p.a);
+    return p * color;
+  }
+]]
+
+local flipShader = nil
+local layer = nil
+
+local function getFlip()
+  if flipShader == nil then
+    local ok, sh = pcall(love.graphics.newShader, FLIP)
+    flipShader = (ok and sh) or false
+  end
+  return flipShader or nil
+end
+
+-- Whether the flip pass can run at all, for the shot driver's log.
+function BattleHud.flipReady()
+  return getFlip() ~= nil
+end
+
+-- Run `fn` with its ink whitened. Falls back to running it plainly when the
+-- scratch layer or the shader is unavailable, so a driver that cannot do
+-- either gets the vanilla black HUD rather than no HUD.
+function BattleHud.flipGlyphs(w, h, fn)
+  local sh = getFlip()
+  if not sh then return fn() end
+  if not layer or layer:getWidth() ~= w or layer:getHeight() ~= h then
+    layer = canvasOf(w, h, "nearest")
+    if not layer then return fn() end
+  end
+
+  local prevCanvas = love.graphics.getCanvas()
+  local prevBlend, prevAlpha = love.graphics.getBlendMode()
+  local ok, err = pcall(function()
+    love.graphics.setCanvas(layer)
+    love.graphics.clear(0, 0, 0, 0)
+    love.graphics.setBlendMode("alpha")
+    fn()
+  end)
+  if prevCanvas then
+    love.graphics.setCanvas(prevCanvas)
+  else
+    love.graphics.setCanvas()
+  end
+  love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
+  if not ok then error(err, 0) end
+
+  love.graphics.setShader(sh)
+  pcall(sh.send, sh, "ink", INK)
+  love.graphics.setColor(1, 1, 1, 1)
+  love.graphics.draw(layer, 0, 0)
+  love.graphics.setShader()
+end
+
+-- The last luminance measured, for the shot driver's log.
+function BattleHud.lastLuma()
+  local best = nil
+  for _, hit in pairs(luma) do
+    if not best or hit.value < best then best = hit.value end
+  end
+  return best
+end
+
+function BattleHud.invalidate()
+  frost, blurA, blurB, probe = nil, nil, nil, nil
+  frostW, frostH = 0, 0
+  luma = {}
+  wasDark = false
+end
+
+return BattleHud
diff --git a/lib/BattlePics.lua b/lib/BattlePics.lua
new file mode 100644
index 0000000..9cafa7b
--- /dev/null
+++ b/lib/BattlePics.lua
@@ -0,0 +1,157 @@
+-- Overworld battles: giving a battle pic its paper back.
+--
+-- Gen 1 battle pics are two-bit art whose lightest shade is WHITE, and the
+-- engine's decoded PNGs key that shade to alpha 0 -- which was free, because
+-- the field behind them was white too. A transparent belly on a white page
+-- is a white belly.
+--
+-- Put a route behind it and the belly is grass. Charizard's chest, the whites
+-- of every eye, the highlight down a Pikachu's cheek: all of it turns into a
+-- hole with the world showing through, and the mon reads as a stencil.
+--
+-- So the paper is put back, and only where the paper was: the pic is read
+-- back once, the transparent region OUTSIDE the figure is flood-filled from
+-- the border, and every transparent pixel the flood could not reach -- every
+-- hole enclosed by the artwork -- is filled opaque white. The silhouette is
+-- untouched, so the mon still cuts cleanly against the world; only its
+-- insides stop being see-through.
+--
+-- Read back off the GPU rather than off the asset, deliberately. What comes
+-- back is the pic the engine actually decided to draw -- species palette,
+-- forced-mono rebuild, shiny recolour, a mod's replacement art -- so this
+-- needs to know nothing about how any of that was arrived at. Once per pic
+-- per session, cached on the image itself.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local BattlePics = {}
+
+-- Cached by the image the engine handed over. Weak keys, so a pic that goes
+-- out of scope takes its filled twin with it rather than pinning a texture
+-- for the session.
+local cache = setmetatable({}, { __mode = "k" })
+
+-- What an enclosed hole is filled with. White, because white is what the
+-- battle field was: this restores the pixel the artist drew and the engine
+-- then keyed away, it does not invent a new one.
+BattlePics.FILL = { 1, 1, 1, 1 }
+
+-- Anything at or under this alpha counts as keyed-out rather than drawn.
+local CUT = 0.5
+
+-- Read the pixels the engine would actually blit. A LOVE Image does not hand
+-- its data back, so it is drawn into a canvas of its own size and the canvas
+-- is read -- which is also what makes this work for every path that produces
+-- a pic, without knowing which one produced this one.
+local function readBack(img)
+  local w, h = img:getDimensions()
+  if w <= 0 or h <= 0 then return nil end
+  local prevCanvas = love.graphics.getCanvas()
+  local prevBlend, prevAlpha = love.graphics.getBlendMode()
+  local prevR, prevG, prevB, prevA = love.graphics.getColor()
+  local data = nil
+  local ok = pcall(function()
+    local canvas = love.graphics.newCanvas(w, h)
+    love.graphics.setCanvas(canvas)
+    love.graphics.clear(0, 0, 0, 0)
+    love.graphics.setBlendMode("replace", "premultiplied")
+    love.graphics.setColor(1, 1, 1, 1)
+    love.graphics.draw(img, 0, 0)
+    love.graphics.setCanvas()
+    data = canvas:newImageData()
+    if canvas.release then pcall(canvas.release, canvas) end
+  end)
+  if prevCanvas then
+    love.graphics.setCanvas(prevCanvas)
+  else
+    love.graphics.setCanvas()
+  end
+  love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
+  love.graphics.setColor(prevR or 1, prevG or 1, prevB or 1, prevA or 1)
+  return ok and data or nil
+end
+
+-- Mark every transparent pixel reachable from the border. That set is the
+-- OUTSIDE; everything transparent it does not reach is an enclosed hole.
+--
+-- An explicit stack rather than recursion: a 56x56 pic is three thousand
+-- pixels and a keyed-out background is most of them, which is a deeper call
+-- chain than is worth risking for no gain.
+local function markOutside(data, w, h)
+  local outside = {}
+  local stack, top = {}, 0
+  local function push(x, y)
+    if x < 0 or y < 0 or x >= w or y >= h then return end
+    local key = y * w + x
+    if outside[key] then return end
+    local _, _, _, a = data:getPixel(x, y)
+    if a > CUT then return end
+    outside[key] = true
+    top = top + 1
+    stack[top] = key
+  end
+  for x = 0, w - 1 do
+    push(x, 0)
+    push(x, h - 1)
+  end
+  for y = 0, h - 1 do
+    push(0, y)
+    push(w - 1, y)
+  end
+  while top > 0 do
+    local key = stack[top]
+    top = top - 1
+    local x, y = key % w, math.floor(key / w)
+    push(x - 1, y)
+    push(x + 1, y)
+    push(x, y - 1)
+    push(x, y + 1)
+  end
+  return outside
+end
+
+-- The pic with its enclosed holes filled, or the pic itself when that could
+-- not be done (no pixel access, a driver that refused the readback). Never
+-- nil for a non-nil argument: a caller must always have something to draw.
+function BattlePics.filled(img)
+  if not img then return img end
+  local hit = cache[img]
+  if hit ~= nil then return hit or img end
+
+  local made = nil
+  local ok = pcall(function()
+    local data = readBack(img)
+    if not data then return end
+    local w, h = data:getDimensions()
+    local outside = markOutside(data, w, h)
+    local fill = BattlePics.FILL
+    local changed = false
+    for y = 0, h - 1 do
+      local row = y * w
+      for x = 0, w - 1 do
+        if not outside[row + x] then
+          local _, _, _, a = data:getPixel(x, y)
+          if a <= CUT then
+            data:setPixel(x, y, fill[1], fill[2], fill[3], fill[4])
+            changed = true
+          end
+        end
+      end
+    end
+    -- nothing enclosed: hand the original back rather than a copy of it
+    if not changed then return end
+    local out = love.graphics.newImage(data)
+    out:setFilter("nearest", "nearest")
+    made = out
+  end)
+
+  cache[img] = (ok and made) or false
+  return made or img
+end
+
+function BattlePics.invalidate()
+  cache = setmetatable({}, { __mode = "k" })
+end
+
+return BattlePics
diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
new file mode 100644
index 0000000..8d80615
--- /dev/null
+++ b/lib/BattleScene.lua
@@ -0,0 +1,422 @@
+-- Overworld battles: one frame of the arena, as geometry.
+--
+-- The same world the free-roam mode draws, from a placed camera instead of
+-- the orbit, at the WINDOW's own pixel resolution -- not the GB's. The
+-- backdrop reaches the screen through Renderer's worldOverride, the seam a
+-- render pipeline's finished world image already composites through, which
+-- is drawn one canvas pixel to one screen pixel; the 160x144 battle screen
+-- then blits over it in the classic letterbox. So the world is as crisp as
+-- the free-roam diorama and the pics, HUDs and text box stay exactly the
+-- chunky GB art they are.
+--
+-- Rendering the whole window rather than just the letterbox means the
+-- framing has to be split in two. The RIG frames the GB's 160x144 (see
+-- BattleCam, which is solved against coordinates in that frame); this
+-- module widens the lens by exactly the ratio the window bears to the
+-- letterbox, so the letterbox sub-rectangle of what gets rendered is
+-- bit-for-bit the framing the rig asked for, and everything outside it is
+-- extra picture. That is what lets the two mons be PINNED: their cells
+-- project to the same GB coordinates at any window size or zoom.
+--
+-- Characters are deliberately absent. The overworld cast is culled for the
+-- length of the battle (see OverworldBattle), so this pass has terrain,
+-- grass and flowers and nothing that walks -- the arena is empty, which is
+-- what makes it an arena.
+--
+-- Everything expensive is shared with the free-roam mode rather than
+-- duplicated: the same chunk meshes out of ChunkMesher, the same palette
+-- atlas out of TerrainAtlas, the same sun out of ShadowMap. A battle on a
+-- map already meshed for walking around costs the frame it draws and
+-- nothing else.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local Mat4 = V.require("Mat4")
+local Voxel3D = V.require("Voxel3D")
+local ShadowMap = V.require("ShadowMap")
+local ChunkMesher = V.require("ChunkMesher")
+local TerrainAtlas = V.require("TerrainAtlas")
+local VoxelScene = V.require("VoxelScene")
+local BattleCam = V.require("BattleCam")
+local BattleBillboard = V.require("BattleBillboard")
+local PaletteFX = require("src.render.PaletteFX")
+
+local BattleScene = {}
+
+-- The GB frame the battle screen is drawn in, and the frame BattleCam's rig
+-- is solved against.
+BattleScene.GB_W = 160
+BattleScene.GB_H = 144
+
+-- A map cell in world pixels: the overworld square a mon stands on, which is
+-- both what the arena is measured in and what a mon is sized to.
+BattleScene.CELL = 16
+
+-- How far into black a shadow goes in the arena, against the free-roam
+-- mode's own lighter setting.
+--
+-- Darker on purpose, and only here. Walking around, a shadow is scenery and
+-- wants to stay out of the way of reading the map. In a battle it is doing
+-- one specific job: the two mons are flat cards, and the ONLY thing telling
+-- the eye they are standing on that floor rather than hanging in front of it
+-- is the shadow they put on it. A faint one leaves them floating.
+BattleScene.SHADOW_ALPHA = 0.68
+
+-- Which rung of the sky ramp an indoor void is painted with. A room has no
+-- sky, but it does have somewhere the geometry stops, and leaving that
+-- transparent would show the letterbox clear through the gaps.
+local INDOOR_SHADE = 4
+
+-- ------- where the GB frame sits inside the window
+--
+-- Renderer blits worldOverride one canvas pixel to one screen pixel and then
+-- blits the 160x144 UI canvas into a centred, integer-scaled letterbox. So
+-- these have to agree with Renderer:endFrame exactly, or the pins land off
+-- the mons by however much they disagree.
+function BattleScene.letterbox()
+  local Renderer = require("src.render.Renderer")
+  local pw, ph = BattleScene.pixelSize()
+  local s = Renderer:fitScale()
+  return math.floor((pw - BattleScene.GB_W * s) / 2),
+         math.floor((ph - BattleScene.GB_H * s) / 2),
+         s, pw, ph
+end
+
+-- The window in FRAMEBUFFER pixels, which is what the override blit works
+-- in. love.graphics.getDimensions is in LOVE units and differs from this by
+-- the display density on mobile.
+function BattleScene.pixelSize()
+  if love.graphics.getPixelDimensions then
+    local pw, ph = love.graphics.getPixelDimensions()
+    if pw and ph and pw > 0 and ph > 0 then return pw, ph end
+  end
+  return love.graphics.getDimensions()
+end
+
+-- Widen the rig's vertical field of view from the GB frame to the whole
+-- window, so the letterbox rows show exactly what the rig framed.
+--
+-- The horizontal falls out of it: at aspect pw/ph the window's half-width is
+-- tan(fov/2) * pw/ph, and the letterbox is 160*s of those pw pixels, which
+-- works back out to the GB frame's own 160/144. So one scale on the vertical
+-- pins both axes.
+function BattleScene.letterboxFov(fovGB, ph, s)
+  local span = BattleScene.GB_H * s
+  if span <= 0 then return fovGB end
+  return 2 * math.atan(math.tan(fovGB / 2) * ph / span)
+end
+
+-- ------- palette
+--
+-- The world palette a map draws under, in the shape VoxelScene's colour
+-- helpers take. Rebuilt per frame from the overworld state, which is where
+-- the engine's own pipeline context gets it too (OverworldController's
+-- ctx.paletteFor).
+local function paletteFor(state, home)
+  return function(map)
+    return PaletteFX.pal(require("src.core.Game").data,
+                         state:paletteNameFor(map or home))
+  end
+end
+
+-- ------- the map the fight is staged on
+--
+-- Normally the one the player is standing on. An authored arena may name
+-- another floor of the same cave or building (see BattleArena), and then the
+-- scene is THAT map: its terrain, its palette, its sky. Nothing else in the
+-- battle changes -- the fight, the party, the player's own position are all
+-- exactly where they were.
+--
+-- A foreign floor is meshed alone, with no connected neighbours: connections
+-- are the player's neighbourhood, and the map the camera has gone to visit is
+-- not standing in it. Both maps are kept live so neither the arena's mesh nor
+-- the one waiting to be walked back onto is evicted mid-battle.
+local function prefetchArena(state, host)
+  if host == state.map then return VoxelScene.prefetch(state) end
+  local live = { [host.id] = true, [state.map.id] = true }
+  for _, nb in ipairs(state.neighbors or {}) do live[nb.map.id] = true end
+  ChunkMesher.setLive(live)
+  TerrainAtlas.setLive(live)
+  local terrain = ChunkMesher.request(host, false, nil, true)
+                  or ChunkMesher.peek(host, true)
+  return terrain, {}
+end
+
+-- ------- the sun
+--
+-- Only has to be drawn once per battle: the arena does not move, and neither
+-- does the light. So the signature is the map, the arena and the meshes --
+-- not the camera, which is the one thing that IS moving and the one thing
+-- the sun does not care about.
+-- ------- the two mons, hung on their cells
+--
+-- The billboard texture is the battle screen's own 160x144 pics layer with
+-- one side rendered into it (see OverworldBattle.sideTexture), so the quad is
+-- that whole frame stood up on the map -- which is what carries every pic
+-- effect the engine applies without any of them being reimplemented here.
+--
+-- Its size follows from one number: a full 7x7-tile mon covers one overworld
+-- square, so a canvas pixel is FULL_W / FULL_PIC world pixels and the card is
+-- the canvas at that scale. Its placement follows from the anchor the
+-- texture reports -- the column the pic was centred on and the row its feet
+-- were put on -- which is translated onto the cell before the card is stood
+-- up, so a mon of any size in any pose has its feet on the ground.
+-- `mirror` flips the card about its own anchor column. Both mons wear their
+-- FRONT pic, which is drawn facing out of the screen -- so dropped into the
+-- world unaltered the pair stand back to back, both looking the same way past
+-- each other. Mirroring the near one turns it to face the far one, which is
+-- what a fight looks like; and because it is a flip about the pic's own
+-- centre the feet do not move off the tile.
+--
+-- The player's TRAINER pic is the exception, and it is exempted below. That
+-- one is a BACK view -- the player seen from behind, already turned to face
+-- up the field -- so it arrives pointing the right way and mirroring it would
+-- turn it around to face the camera it is standing in front of.
+local function monMatrix(tex, x, groundY, z, mirror)
+  local k = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC
+  local w = BattleScene.GB_W * k
+  local h = BattleScene.GB_H * k
+  local ox = -((tex.ax / BattleScene.GB_W) - 0.5) * w
+  local oy = -((BattleScene.GB_H - tex.ay) / BattleScene.GB_H) * h
+  local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
+  local card = Mat4.mul(Mat4.translate(ox, oy, 0), Mat4.scale(w, h, 1))
+  if mirror then card = Mat4.mul(Mat4.scale(-1, 1, 1), card) end
+  return Mat4.mul(Mat4.mul(Mat4.translate(x, groundY, z), Mat4.rotateY(yaw)),
+                  card)
+end
+
+-- Every mon that has something to show this frame, as (texture, matrix).
+local function monCards(arena, groundY, textures)
+  local out = {}
+  if not textures then return out end
+  for _, side in ipairs({ "enemy", "player" }) do
+    local tex = textures[side]
+    local cell = (side == "player") and arena.player or arena.enemy
+    if tex and tex.canvas and cell then
+      local mirror = (side == "player") and not tex.trainer
+      out[#out + 1] = { tex = tex.canvas,
+                        model = monMatrix(tex, cell[1], groundY, cell[2],
+                                          mirror) }
+    end
+  end
+  return out
+end
+
+BattleScene.monCards = monCards
+
+-- The sun has to see the mons too, or they stand on the ground without
+-- putting anything on it. They are the one thing in this scene that MOVES,
+-- so `token` -- a counter the caller bumps whenever a pic could have changed
+-- -- goes in the signature; the terrain half of the answer would otherwise
+-- keep a stale pass alive and freeze the shadows in whatever pose they were
+-- first drawn in.
+local function shadowSignature(state, arena, terrain, nbMesh, token)
+  local host = arena.map or state.map
+  local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
+                  tostring(terrain), tostring(token or 0) }
+  for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
+  return table.concat(parts, ",")
+end
+
+local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
+                           atlasFor, cards, token, host, neighbors)
+  if not ShadowMap.available() then return end
+  local sig = shadowSignature(state, arena, terrain, nbMesh, token)
+  if not ShadowMap.stale(sig) then return end
+  if not ShadowMap.begin(cx, cy, vw, vh) then return end
+
+  ShadowMap.draw(terrain, atlasFor(host), nil)
+  for i, nb in ipairs(neighbors) do
+    ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy))
+  end
+  ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), nil)
+  for _, nb in ipairs(neighbors) do
+    ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
+                   Mat4.translate(nb.ox, 0, nb.oy))
+  end
+
+  -- the mons themselves, as the same cards the camera will see. Their alpha
+  -- is the silhouette, so what lands on the ground is the shape of the
+  -- Pokemon rather than a blob standing in for one.
+  for _, card in ipairs(cards or {}) do
+    ShadowMap.draw(BattleBillboard.mesh(), card.tex, card.model)
+  end
+
+  ShadowMap.finish(sig)
+end
+
+-- The height of the arena floor: the ground the two mons stand on. Both
+-- cells are open, so they are normally the same; take the player's, which is
+-- the one nearer the camera and therefore the one a mismatch would show up
+-- against.
+function BattleScene.groundY(map, arena)
+  local ok, h = pcall(VoxelScene.groundAt, map,
+                      arena.playerCell[1], arena.playerCell[2])
+  return (ok and h) or 0
+end
+
+-- Where a world point lands in GB frame coordinates under `vp`, or nil when
+-- it is behind the camera. This is the function the pins are built on: it
+-- takes the window-resolution clip position and divides the letterbox back
+-- out of it, so the answer is in the same 160x144 space the battle screen
+-- draws its pics in.
+function BattleScene.toGB(vp, wx, wy, wz, lx, ly, s, pw, ph)
+  local cx = vp[1] * wx + vp[2] * wy + vp[3] * wz + vp[4]
+  local cy = vp[5] * wx + vp[6] * wy + vp[7] * wz + vp[8]
+  local cw = vp[13] * wx + vp[14] * wy + vp[15] * wz + vp[16]
+  if cw <= 1e-6 then return nil end
+  -- viewProjection already flipped clip Y into LOVE's Y-down convention
+  local px = (cx / cw * 0.5 + 0.5) * pw
+  local py = (cy / cw * 0.5 + 0.5) * ph
+  return (px - lx) / s, (py - ly) / s
+end
+
+-- Render the arena and hand back { canvas, player = {x,y}, enemy = {x,y} },
+-- the two marks in GB coordinates -- or nil when there is nothing to draw
+-- yet (the terrain mesh is still building, the driver has no depth support).
+-- nil is not a failure: the caller simply leaves the battle screen as the
+-- engine drew it for that frame.
+function BattleScene.render(state, arena, textures, token)
+  if not (state and state.map and arena) then return nil end
+  if not Voxel3D.available() then return nil end
+
+  -- the floor the fight is staged on: normally the player's own, sometimes
+  -- another floor of the same cave or building (see BattleArena)
+  local host = arena.map or state.map
+  local neighbors = (host == state.map) and (state.neighbors or {}) or {}
+
+  -- shares the free-roam mode's request/evict bookkeeping, so a battle warms
+  -- exactly the meshes walking around would have and nothing extra
+  local terrain, nbMesh = prefetchArena(state, host)
+  if not terrain then return nil end
+
+  local lx, ly, s, pw, ph = BattleScene.letterbox()
+  if not (pw > 0 and ph > 0 and s > 0) then return nil end
+
+  local palette = paletteFor(state, host)
+  local function atlasFor(map)
+    return TerrainAtlas.forMap(map, VoxelScene._modeColors(palette, map))
+  end
+
+  local groundY = BattleScene.groundY(host, arena)
+  local cam, pitch = BattleCam.rig(arena, groundY)
+  cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
+
+  local cx, cy = arena.mid[1], arena.mid[2]
+  -- the world extents the sun frustum is fitted to; the camera itself is
+  -- framed by cam.fov, so these only have to describe the ground in shot
+  local vh = BattleCam.rigFor(arena).frameH * ph / (BattleScene.GB_H * s)
+  local vw = vh * pw / ph
+
+  -- the cards need the camera's eye to face it, so the rig has to be live
+  -- before they are built; Voxel3D.eye is set by viewProjection, which
+  -- beginScene calls -- so a provisional one is taken here for the sun pass
+  -- and the real one is rebuilt inside the scene below.
+  Voxel3D.camera = cam
+  Voxel3D.viewProjection(cx, cy, vw, vh)
+  local cards = monCards(arena, groundY, textures)
+  Voxel3D.camera = nil
+  castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor,
+              cards, token, host, neighbors)
+
+  -- An opaque void either way. Outdoors the camera is low enough that the
+  -- horizon is genuinely in frame, so it is sky; indoors it is the dark end
+  -- of the same ramp, which is a room's "past the wall". Transparent -- the
+  -- free-roam default -- would let the letterbox clear through wherever the
+  -- geometry stops.
+  local sky = VoxelScene.skyColor(host, 1)
+             or VoxelScene.skyShade(INDOOR_SHADE, 1)
+
+  Voxel3D.camera = cam
+  -- the sun is turned up for the arena and put back afterwards, so the
+  -- free-roam world it shares this module with keeps its own weight
+  local sunWas = Voxel3D.SHADOW_ALPHA
+  Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA
+  local out = nil
+  local ok, err = pcall(function()
+    -- its own canvas slot: this renders at the window's pixel size and the
+    -- free-roam pass does too, but the two are alive at different moments
+    -- and a shared slot would reallocate on every battle entry and exit
+    if not Voxel3D.beginScene(pw, ph, cx, cy, vw, vh, sky, "battle") then
+      return
+    end
+    Voxel3D.draw(terrain, atlasFor(host), nil)
+    for i, nb in ipairs(neighbors) do
+      Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
+                   Mat4.translate(nb.ox, 0, nb.oy))
+    end
+    -- The mons, standing on their tiles. Depth-tested like everything else,
+    -- so a ledge or a tree between the camera and a Pokemon really is in
+    -- front of it, and the alpha discard cuts the sprite's own outline out of
+    -- the card. A small camera-ward pull keeps a card rooted to the ground
+    -- plane from z-fighting the tile it is standing on.
+    for _, card in ipairs(monCards(arena, groundY, textures)) do
+      Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
+                   BattleBillboard.PULL)
+    end
+    -- grass and flowers ride the same camera-ward pull the free-roam pass
+    -- gives them, measured against THIS camera's pitch rather than the
+    -- orbit's -- there is no character here for them to overdraw, but the
+    -- pull is also what keeps a tuft from z-fighting the floor it stands on
+    local pull = VoxelScene.pull(math.max(pitch, 0.05))
+    Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull)
+    for _, nb in ipairs(neighbors) do
+      Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
+                   Mat4.translate(nb.ox, 0, nb.oy), pull)
+    end
+    local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
+    Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull)
+    for _, nb in ipairs(neighbors) do
+      Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
+                   Mat4.translate(nb.ox, 0, nb.oy), fpull)
+    end
+    local canvas = Voxel3D.endScene()
+    if not canvas then return end
+
+    local vp = Voxel3D.vp
+    local pmx, pmy = BattleScene.toGB(vp, arena.player[1], groundY,
+                                      arena.player[2], lx, ly, s, pw, ph)
+    local emx, emy = BattleScene.toGB(vp, arena.enemy[1], groundY,
+                                      arena.enemy[2], lx, ly, s, pw, ph)
+    if not (pmx and emx) then return end
+    -- How wide one overworld square is on screen where each mon stands, in
+    -- GB pixels. This is what the pics are scaled to: a mon covers its own
+    -- square and no more, at whatever the drift has done to the distance.
+    local half = BattleScene.CELL / 2
+    local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY,
+                                arena.player[2], lx, ly, s, pw, ph)
+    local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY,
+                                arena.player[2], lx, ly, s, pw, ph)
+    local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY,
+                                arena.enemy[2], lx, ly, s, pw, ph)
+    local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY,
+                                arena.enemy[2], lx, ly, s, pw, ph)
+    if not (pl and pr and el and er) then return end
+    out = {
+      canvas = canvas,
+      player = { pmx, pmy },
+      enemy = { emx, emy },
+      playerSpan = math.abs(pr - pl),
+      enemySpan = math.abs(er - el),
+      -- the letterbox, so the depth-of-field pass can put its sharp band on
+      -- the two marks rather than on a fraction of the window
+      lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
+    }
+  end)
+  -- the placed camera is ours for exactly this pass; anything else that
+  -- renders (the free-roam pipeline, next frame) must find the orbit back
+  Voxel3D.camera = nil
+  Voxel3D.SHADOW_ALPHA = sunWas
+  if not ok then
+    -- endScene never ran, so the canvas is still bound and the shader still
+    -- set; put the frame back the way it was found before rethrowing
+    pcall(love.graphics.setShader)
+    pcall(love.graphics.setDepthMode)
+    pcall(love.graphics.setCanvas)
+    error(err, 0)
+  end
+  return out
+end
+
+return BattleScene
diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua
new file mode 100644
index 0000000..672e445
--- /dev/null
+++ b/lib/OverworldBattle.lua
@@ -0,0 +1,683 @@
+-- Overworld battles: fights that happen on the map you were standing on.
+--
+-- The engine's battle is a screen: a white field with two pics on it, pushed
+-- over a frozen overworld that stops drawing. This turns that white field
+-- into the world -- the same terrain the free-roam mode extrudes, shot from
+-- a placed over-the-shoulder camera at a clear patch of ground nearby --
+-- while leaving the battle ITSELF alone. Every pic, HUD, HP bar, move
+-- animation, faint slide and text box is the engine's own, drawn in the
+-- engine's own order. What changes is what is behind them, and where the two
+-- pics stand.
+--
+-- The sequence, from the moment something picks a fight:
+--
+--   1. the overworld cast is culled -- every NPC vanishes, so the wipe
+--      plays over an empty map and no bystander is left standing in the
+--      arena shot
+--   2. the engine's own transition wipes the screen (untouched: it is the
+--      right wipe, picked by the right three bits)
+--   3. the battle draws over a live, window-resolution render of the arena,
+--      with each mon PINNED to the cell it is standing on, the camera
+--      drifting slowly enough to read as parallax, and a depth-of-field pass
+--      holding the slab of world the two of them occupy sharp
+--   4. the battle ends, the cast comes back, and the player is exactly
+--      where they were standing
+--
+-- WHAT DOES NOT MOVE. The arena is where the CAMERA goes, not where the
+-- player goes: nothing here writes a cell, a facing, a flag or a warp. A
+-- real warp would have to survive trainer sight-lines, post-battle
+-- dialogue, the blackout path and every script that assumes the player is
+-- where it left them -- and it would have to put them back afterwards.
+-- Moving the camera buys the whole shot and owes nothing back.
+--
+-- The feature declines cleanly rather than half-working: no depth support,
+-- no open ground on the map, the row switched off, or a mesh still building
+-- all end at the same place, which is the battle screen the engine has
+-- always drawn.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local ModSetting = V.require("ModSetting")
+local BattleArena = V.require("BattleArena")
+local BattleCam = V.require("BattleCam")
+local BattleScene = V.require("BattleScene")
+local BattleDOF = V.require("BattleDOF")
+local BattleHud = V.require("BattleHud")
+local BattlePics = V.require("BattlePics")
+local Voxel3D = V.require("Voxel3D")
+local ChunkMesher = V.require("ChunkMesher")
+
+local OverworldBattle = {}
+
+-- DS_BATTLE_DEBUG=1 logs what the HUD's brightness probe is reading, once a
+-- second, which is how the glyph flip is checked from a shot run. Read
+-- through pcall: the loader's sandbox does not hand a mod `os`, and a
+-- diagnostic must never be the reason the mod fails to load.
+local DEBUG = select(2, pcall(function() return os.getenv("DS_BATTLE_DEBUG") end))
+if DEBUG == nil or DEBUG == false then DEBUG = nil end
+
+OverworldBattle.KEY = "battles"
+OverworldBattle.LABEL = "3D-BTL"
+
+-- On by default: a mod whose headline is "the world in 3D" should not need
+-- the player to go and find the switch before the world shows up in a
+-- battle. ON is first, so it is also what an unreadable stored value falls
+-- back to.
+OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY,
+                                         OverworldBattle.LABEL,
+                                         { true, false }, { "ON", "OFF" })
+
+function OverworldBattle.enabled()
+  return OverworldBattle.setting:get() and true or false
+end
+
+-- ------- both mons face you
+--
+-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
+-- wrong twice over: it is turned away from the camera that is looking at it,
+-- and the back pics are a different, smaller drawing made for a slot the
+-- player never really sees. So the player's side asks for the FRONT pic too,
+-- through the engine's own pokemon.sprite hook -- the seam that exists for
+-- exactly this, so no battle code has to be touched to get it.
+--
+-- Answered BEFORE a battle exists, because the battler is built before the
+-- battle is pushed. So it cannot ask whether this fight is staged; it asks
+-- whether one on this map WOULD be -- the row is on, the 3D pass is
+-- available, and the map has an arena -- which is the same question with the
+-- same answer a moment later. Cached per map, because the arena search walks
+-- the whole grid and this runs once per battler.
+local staged = { mapId = nil, ok = false }
+
+function OverworldBattle.wantsFront()
+  if not OverworldBattle.enabled() then return false end
+  if not Voxel3D.available() then return false end
+  -- required here rather than through the file's own helper: this runs
+  -- while a battler is being built, which is before that helper is defined
+  local g = require("src.core.Game")
+  local ow = g and g.overworld
+  if not (ow and ow.map and ow.player) then return false end
+  if staged.mapId ~= ow.map.id then
+    local ok, arena = pcall(BattleArena.find, ow.map,
+                            ow.player.cellX, ow.player.cellY,
+                            ow.player.surfing)
+    staged = { mapId = ow.map.id, ok = (ok and arena) and true or false }
+  end
+  return staged.ok
+end
+
+-- ------- where the engine's own pics stand
+--
+-- The GB draws the player's back pic with its feet on the text box at row 96
+-- and its 7x7-tile slot centred on x=40, and the enemy's front pic
+-- bottom-aligned in a 7x7 slot centred on x=124 ending at row 56. Those two
+-- points are the pics' FEET, they hold for every species at every scale (the
+-- engine's placement helpers pin the bottom edge and the centre), and they
+-- are what BattleCam is solved to put the two arena cells under.
+--
+-- Which makes the pin a subtraction: whatever the drift has done to the
+-- camera this frame, each pic moves by its own cell's projected position
+-- minus its anchor. At the middle of the drift that is zero.
+OverworldBattle.ANCHOR = {
+  player = { 26, 96 },
+  enemy = { 124, 56 },
+}
+
+-- ------- how big a mon is
+--
+-- Not a decision made here. A pic is drawn at its own integer scale -- 1x for
+-- a 56px front pic, 2x for a 32px back one -- because that is the only way it
+-- keeps every pixel the artist drew, and the CAMERA is solved so that one
+-- overworld square is that big on screen (see BattleCam). The mon fits its
+-- tile because the tile was sized to the mon, not the other way round.
+OverworldBattle.SLOT_W = { front = 56, back = 32 }
+
+-- The two HUD blocks, as the pixel spans DrawEnemyHUDAndHPBar and
+-- DrawPlayerHUDAndHPBar actually reach. Neither overlaps its side's pic at
+-- the anchors above.
+OverworldBattle.HUD_RECT = {
+  enemy = { 8, 0, 80, 32 },
+  player = { 72, 56, 88, 40 },
+}
+
+-- ------- the live battle
+--
+-- nil when no overworld battle is running. Never more than one: battles do
+-- not nest.
+local session = nil
+
+local function game()
+  return require("src.core.Game")
+end
+
+-- Put the map's cast back. Both lists are handed back by identity, so
+-- anything that captured one before the battle still sees the same table.
+local function restoreCast()
+  if not (session and session.state) then return end
+  if session.entities then session.state.entities = session.entities end
+  if session.ghosts then session.state.ghosts = session.ghosts end
+  session.entities, session.ghosts = nil, nil
+end
+
+-- Cull them. The player stays -- they are not an NPC, they are who the
+-- battle belongs to, and Fly/surf animations and the save's own capture read
+-- state.player through this list.
+--
+-- Only the DRAW lists are touched, and only while the overworld is frozen
+-- underneath a battle: StateStack updates the top state alone, so nothing
+-- walks, wanders, triggers or collides against a list that is short for
+-- these frames. The originals go back at battle.ended.
+local function cullCast(state)
+  session.entities = state.entities
+  session.ghosts = state.ghosts
+  state.entities = { state.player }
+  state.ghosts = {}
+end
+
+-- Stage a battle triggered from `state`, if this mode can. Returns true when
+-- a session started -- which is also the only case where anything visible
+-- changes, so a map with no room for an arena plays exactly the vanilla
+-- battle it always did, cast and all.
+function OverworldBattle.begin(state, battle)
+  OverworldBattle.finish()
+  if not OverworldBattle.enabled() then return false end
+  if not (state and state.map and state.player) then return false end
+  if not Voxel3D.available() then return false end
+
+  local ok, arena = pcall(BattleArena.find, state.map,
+                          state.player.cellX, state.player.cellY,
+                          state.player.surfing)
+  if not (ok and arena) then return false end
+
+  session = { state = state, arena = arena, battle = battle, shot = nil,
+              armed = false, token = 0 }
+  cullCast(state)
+  BattleCam.reset()
+  return true
+end
+
+-- The fallback entry point: a battle that arrived without going through the
+-- overworld's own pushBattle (a link battle, a script pushing a BattleState
+-- directly). Nothing visible depends on the cull for those -- the wipe has
+-- already been and gone -- but the arena still has to be picked.
+function OverworldBattle.ensure(battle)
+  if session then
+    -- a battle pushed through the overworld reaches begin() before it is
+    -- built far enough to draw; battle.started is where it is finished
+    if battle and not session.battle then session.battle = battle end
+    return
+  end
+  local g = game()
+  local ow = g and g.overworld
+  if ow and ow.map then OverworldBattle.begin(ow, battle) end
+end
+
+-- The arena this battle is staged on, or nil. Read by the shot driver so a
+-- screenshot can be labelled with the ground it was taken on.
+function OverworldBattle.arena()
+  return session and session.arena or nil
+end
+
+function OverworldBattle.finish()
+  if not session then return end
+  restoreCast()
+  session = nil
+  Voxel3D.camera = nil
+end
+
+-- ------- per-frame
+--
+-- Driven from the voxel pipeline's update hook, which the engine ticks every
+-- frame regardless of which state is on top -- including the frames the
+-- transition wipe covers, which is what gets the arena's meshes built before
+-- the first battle frame needs them.
+--
+-- The scene is rendered HERE rather than inside the battle's draw, because
+-- update runs with no canvas bound: a 3D pass that binds a depth target and
+-- unbinds to the screen when it is done cannot do that in the middle of
+-- someone else's frame without putting the frame back itself.
+function OverworldBattle.update(dt)
+  if not session then return end
+
+  local g = game()
+  local top = g and g.stack and g.stack:top()
+  local ow = g and g.overworld
+  -- A battle that ended without saying so (a script tearing the state down,
+  -- a path that never emits battle.ended) would otherwise leave the cast
+  -- culled for good. Armed only once something has actually covered the
+  -- overworld, because begin() runs while the overworld is still on top.
+  if top ~= nil and top ~= ow then
+    session.armed = true
+  elseif session.armed then
+    OverworldBattle.finish()
+    return
+  end
+
+  BattleCam.update(dt)
+  -- the battle only exists once it has been pushed; a session opened at
+  -- pushBattle time has it, one opened from battle.started was handed it
+  session.battle = session.battle or (top ~= ow and top or nil)
+  -- the world pass is hidden behind the battle, so mesh builds get the wide
+  -- slice: nothing visible can hitch on them
+  ChunkMesher.pump(true)
+
+  -- The mons' textures are rendered HERE, with no canvas bound, for the same
+  -- reason the scene is: the pics layer binds its own targets, and doing that
+  -- inside somebody else's frame means putting the frame back afterwards.
+  local okTex, textures = pcall(OverworldBattle.textures, session.battle)
+  if not okTex then textures = nil end
+  session.token = (session.token or 0) + 1
+  local ok, shot = pcall(BattleScene.render, session.state, session.arena,
+                         textures, session.token)
+  if not ok then
+    -- One failure retires the arena for THIS battle and nothing else: the
+    -- battle screen carries on as the engine's own, the free-roam pipeline
+    -- this runs inside keeps rendering the overworld, and the next battle
+    -- tries again. Rethrowing would hand the whole voxel mode to Pipelines'
+    -- guard, which retires a pipeline for the session.
+    session.shot = nil
+    session.broken = true
+    V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
+                   .. "on the plain battle background", tostring(shot))
+    return
+  end
+  if shot and shot.canvas then
+    -- the depth of field is measured off the two marks: the slab in focus is
+    -- the one the mons are standing in, at whatever the drift has done to
+    -- where that lands
+    local y1 = shot.ly + shot.player[2] * shot.scale
+    local y2 = shot.ly + shot.enemy[2] * shot.scale
+    local focusY, band, range = BattleDOF.bandFor(y1, y2, shot.ph)
+    local okDof, blurred = pcall(BattleDOF.apply, shot.canvas,
+                                 focusY, band, range)
+    if okDof and blurred then shot.canvas = blurred end
+    -- the frosted glass the HUDs sit on is built from the FINISHED backdrop,
+    -- so a panel over a blurred far field is frosted from what is actually
+    -- behind it
+    pcall(BattleHud.build, shot.canvas)
+  end
+  session.shot = shot
+end
+
+-- The finished shot for this frame, or nil when there is none and the battle
+-- should draw the way it always did.
+function OverworldBattle.shot()
+  if not session or session.broken then return nil end
+  local s = session.shot
+  if s and s.canvas then return s end
+  return nil
+end
+
+function OverworldBattle.invalidate()
+  BattleDOF.invalidate()
+  BattleHud.invalidate()
+  BattlePics.invalidate()
+end
+
+-- ------- the battle screen's background
+--
+-- BattleState opens by filling 160x144 white -- that fill IS the battle's
+-- background, and in the colorized pipeline it is also the BG canvas's clear
+-- (nothing else clears it, so skipping it outright would ghost last frame).
+-- So for the length of one draw, that one call is intercepted: on the two
+-- offscreen canvases it becomes a transparent clear, so the shade-remap pass
+-- composites the HUD and the text box over the arena and leaves the empty
+-- field showing it; on the screen it is simply dropped, because the UI canvas
+-- has already been cleared transparent for the world to show through.
+--
+-- Matched exactly -- fill, the full frame, at the origin, in opaque white --
+-- so the text box (a 20x6 box lower down), a mon pic, an HP bar and the
+-- move-animation flash (which is white at 0.85) all pass through untouched.
+--
+-- This is a shim over love.graphics and it is the one invasive thing here,
+-- so it is scoped as tightly as it can be: installed around a single call,
+-- removed on the way out including on error, and never live outside a battle
+-- frame this mode is drawing.
+local function withoutBackgroundFill(battle, fn)
+  local g = love.graphics
+  local rectangle = g.rectangle
+  g.rectangle = function(mode, x, y, w, h, ...)
+    if mode == "fill" and x == 0 and y == 0
+       and w == BattleScene.GB_W and h == BattleScene.GB_H then
+      local r, gr, b, a = g.getColor()
+      if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then
+        local target = g.getCanvas()
+        if target ~= nil
+           and (target == battle.bgCanvas or target == battle.waveCanvas) then
+          g.clear(0, 0, 0, 0)
+        end
+        return
+      end
+    end
+    return rectangle(mode, x, y, w, h, ...)
+  end
+  local ok, err = pcall(fn, battle)
+  g.rectangle = rectangle
+  if not ok then error(err, 0) end
+end
+
+-- ------- the mons, as textures for the 3D pass
+--
+-- The two Pokemon are not composited over the world any more: they are quads
+-- standing in it (see BattleBillboard). What that needs from the battle
+-- screen is a TEXTURE per side -- and the honest way to get one is to let the
+-- engine draw its own pics layer, unchanged, into a canvas.
+--
+-- So the layer is rendered twice, once per side, with the other side
+-- falsified out of existence by nulling exactly the fields its branches
+-- test. Everything the engine does to a pic comes along for free that way:
+-- the trainer pic before the send-out, the grow-out-of-the-ball scale, the
+-- faint slide, the damage blink, the squish, every SE displacement. None of
+-- it is reimplemented and none of it can drift.
+--
+-- Two things are forced during that render. The scale, to 1, so the texture
+-- carries the artwork's own pixels and the BILLBOARD does the sizing; and the
+-- placement, so the pic lands centred on a known column with its feet on a
+-- known row. That known point is what the quad is then hung from.
+local TEX_AX, TEX_AY = 80, 96          -- forced pic centre and baseline
+local TRAINER_AX, TRAINER_AY = 124, 56 -- the intro trainer pic's own slot
+
+OverworldBattle.TEX_AX, OverworldBattle.TEX_AY = TEX_AX, TEX_AY
+
+-- Which side is being rendered, or nil. The placement wrappers read it.
+local texturing = nil
+
+local texCanvas = {}
+local innerPics = nil                   -- captured by install()
+
+local function texCanvasFor(side)
+  local c = texCanvas[side]
+  if c then return c end
+  local ok, made = pcall(love.graphics.newCanvas, BattleScene.GB_W,
+                         BattleScene.GB_H, { dpiscale = 1 })
+  if not ok then return nil end
+  made:setFilter("nearest", "nearest")
+  texCanvas[side] = made
+  return made
+end
+
+-- Whether this side has anything to draw at all. Mirrors drawPicsLayer's own
+-- guards, so an empty canvas is never hung on a quad: a fainted, hidden or
+-- not-yet-sent-out mon simply has no billboard this frame.
+local function sideVisible(battle, side)
+  if side == "enemy" then
+    if battle.showEnemyTrainer and battle.trainerPic then return true end
+    return (battle.enemy and battle.enemy.sprite and not battle.enemyHidden
+            and not battle.enemySendingOut
+            and not battle:fxHidden(battle.enemy)) and true or false
+  end
+  if battle.showPlayerBack and battle.playerBackPic then return true end
+  local hide = battle.safari or battle.demo
+  return (battle.player and battle.player.sprite and not hide
+          and not battle.sendingOut
+          and not battle:fxHidden(battle.player)) and true or false
+end
+
+local OFF = {
+  enemy = { player = false, showPlayerBack = false },
+  player = { enemy = false, showEnemyTrainer = false },
+}
+
+-- Render one side's pics layer into its canvas and report where the pic's
+-- feet ended up, in canvas coordinates.
+function OverworldBattle.sideTexture(battle, side)
+  if not (innerPics and battle) then return nil end
+  if not sideVisible(battle, side) then return nil end
+  local canvas = texCanvasFor(side)
+  if not canvas then return nil end
+
+  local g = love.graphics
+  local prevCanvas = g.getCanvas()
+  local prevBlend, prevAlpha = g.getBlendMode()
+  -- The pic-window scissors are in the battle screen's fixed coordinates and
+  -- would clip a pic that has been moved to the middle of its own canvas.
+  -- There is nothing here for them to protect -- no HUD, no text box, just
+  -- the one pic -- so they are switched off for the render.
+  local setScissor, intersectScissor = g.setScissor, g.intersectScissor
+  local getScissor = g.getScissor
+  g.setScissor = function() end
+  g.intersectScissor = function() end
+  g.getScissor = function() return nil end
+
+  local saved = {}
+  for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end
+  texturing = side
+
+  local ok, err = pcall(function()
+    g.setCanvas(canvas)
+    g.clear(0, 0, 0, 0)
+    g.setBlendMode("alpha")
+    g.setColor(1, 1, 1, 1)
+    innerPics(battle, 0, 0, 0)
+  end)
+
+  texturing = nil
+  for k in pairs(OFF[side]) do battle[k] = saved[k] end
+  g.setScissor, g.intersectScissor, g.getScissor =
+    setScissor, intersectScissor, getScissor
+  if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
+  g.setBlendMode(prevBlend or "alpha", prevAlpha)
+  if not ok then error(err, 0) end
+
+  local ax, ay = TEX_AX, TEX_AY
+  local trainer = false
+  -- The intro trainer pic draws itself straight into its own 7x7 slot rather
+  -- than through the placement helpers, so it is hung from that slot instead.
+  if side == "enemy" and battle.showEnemyTrainer and battle.trainerPic then
+    ax, ay, trainer = TRAINER_AX, TRAINER_AY, true
+  elseif side == "player" and battle.showPlayerBack and battle.playerBackPic then
+    trainer = true
+  end
+  return { canvas = canvas, ax = ax, ay = ay, trainer = trainer }
+end
+
+-- Both sides, or nil when neither has anything to show.
+function OverworldBattle.textures(battle)
+  if not battle then return nil end
+  local out = {}
+  local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
+  local okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
+  out.enemy = okE and enemy or nil
+  out.player = okP and player or nil
+  if not (out.enemy or out.player) then return nil end
+  return out
+end
+
+-- ------- engine seams
+--
+-- Four wraps, each idempotent so a hot reload cannot stack them.
+
+function OverworldBattle.install()
+  local OverworldState = require("src.world.OverworldController")
+  if not OverworldState.dramaticShapeBattleHook then
+    local inner = OverworldState.pushBattle
+    -- The one place the overworld starts a battle, and it runs BEFORE the
+    -- transition is pushed -- which is what lets the cull happen off-screen
+    -- and the wipe play over a map with nobody on it.
+    function OverworldState:pushBattle(battle)
+      pcall(OverworldBattle.begin, self, battle)
+      return inner(self, battle)
+    end
+    OverworldState.dramaticShapeBattleHook = true
+  end
+
+  local BattleState = require("src.battle.BattleState")
+  if BattleState.dramaticShapeBattleHook then return end
+
+  -- Integer scales only. The camera is solved to make one overworld square
+  -- exactly big enough for a pic at its own integer scale (see BattleCam), so
+  -- the fit never has to come out of the pixels -- and a species override or
+  -- a battle_sprite_scales entry that asks for 1.7x would undo that and
+  -- resample the sprite into mush. Rounded rather than refused, so such a mod
+  -- still gets the bigger or smaller mon it asked for, on the pixel grid.
+  local innerScale = BattleState.resolveBattleScale
+  function BattleState.resolveBattleScale(data, side, path, species)
+    local base = innerScale(data, side, path, species)
+    -- 1:1 into the billboard texture: the artwork's own pixels, with the
+    -- quad's world size doing every bit of the scaling. Anything else would
+    -- resample the sprite twice -- once into the texture and again on the way
+    -- to the screen -- and a twice-resampled Gen 1 pic is mush.
+    if texturing then return 1 end
+    if not OverworldBattle.shot() then return base end
+    return math.max(1, math.floor((tonumber(base) or 1) + 0.5))
+  end
+
+  -- Keyed-out whites inside a pic used to be filled by the white field
+  -- behind it. There is a world back there now, so they are filled here
+  -- instead -- see BattlePics, which puts the paper back without touching
+  -- the silhouette.
+  local innerPic = BattleState.picImage
+  function BattleState:picImage(img)
+    local out = innerPic(self, img)
+    if not OverworldBattle.shot() then return out end
+    return BattlePics.filled(out)
+  end
+
+  -- While a billboard texture is being rendered both pics are put in the same
+  -- known place -- centred on TEX_AX with their feet on TEX_AY -- so the quad
+  -- has one anchor to hang from whichever side and whichever species it is
+  -- carrying. Outside that render both helpers answer exactly as they always
+  -- did.
+  local innerBack = BattleState.backPlacement
+  function BattleState.backPlacement(w, h, pad, padL, scale)
+    local x, y, s = innerBack(w, h, pad, padL, scale)
+    if not texturing then return x, y, s end
+    return TEX_AX - w * scale / 2, TEX_AY - (h - pad) * scale, s
+  end
+
+  local innerFront = BattleState.frontPlacement
+  function BattleState.frontPlacement(ex, ey, w, h, scale)
+    local x, y, s = innerFront(ex, ey, w, h, scale)
+    if not texturing then return x, y, s end
+    return TEX_AX - w * scale / 2, TEX_AY - h * scale, s
+  end
+
+  local innerDraw = BattleState.draw
+  function BattleState:draw()
+    local shot = OverworldBattle.shot()
+    -- AskName blanks the field on purpose (the nickname prompt is meant to
+    -- sit on nothing); leave that one alone.
+    if not shot or self.blankForAskName then
+      -- nil, not false: the class default is inherited again, so a battle
+      -- that loses its arena mid-fight goes back to white voids
+      self.letterboxWhite = nil
+      self.dramaticShapeShot = nil
+      return innerDraw(self)
+    end
+    self.dramaticShapeShot = shot
+    -- The world reaches the screen through the seam a render pipeline's
+    -- finished world image already uses: one window-resolution canvas,
+    -- blitted a pixel to a pixel, with the 160x144 UI canvas composited over
+    -- it in the classic letterbox afterwards. That is what makes the backdrop
+    -- as crisp as the free-roam diorama while the pics and text stay GB art.
+    local renderer = game().renderer
+    if renderer and renderer.setWorldOverride then
+      renderer:setWorldOverride(shot.canvas)
+    end
+    -- beginFrame clears the UI canvas white for an opaque state; the world is
+    -- under it now, so clear it back to nothing and let it through. Safe to
+    -- do here: an opaque battle is the lowest state drawn, so nothing has
+    -- drawn into this canvas yet.
+    love.graphics.clear(0, 0, 0, 0)
+    -- the white letterbox exists so the window matches the white battle
+    -- canvas; there is a world out to the window edges now
+    self.letterboxWhite = false
+    OverworldBattle.drawHudPanels(self)
+    withoutBackgroundFill(self, innerDraw)
+  end
+
+  -- The mons are geometry standing on the map now, drawn in the 3D pass
+  -- before this screen is composited at all, so the flat pics layer has
+  -- nothing left to do here. Skipped rather than left to draw underneath, or
+  -- every Pokemon would appear twice: once on its tile and once in its slot.
+  innerPics = BattleState.drawPicsLayer
+  function BattleState:drawPicsLayer(slide, sx, sy)
+    if self.dramaticShapeShot then return end
+    return innerPics(self, slide, sx, sy)
+  end
+
+  -- Move animations are authored against the pics' fixed slots, and a single
+  -- animation reaches across both sides, so there is no per-side offset to
+  -- give them. They ride the average, which is where the pair's centre went
+  -- -- a few pixels at most, and it keeps a hit landing on the mon it is
+  -- aimed at instead of drifting off it.
+  local innerAnim = BattleState.drawAnimLayer
+  function BattleState:drawAnimLayer(colorized)
+    local shot = self.dramaticShapeShot
+    if not shot then return innerAnim(self, colorized) end
+    -- Move animations are authored against the pics' old fixed slots, and one
+    -- animation reaches across both sides, so there is no per-side offset to
+    -- give them. They ride to where the PAIR went: the midpoint of the two
+    -- mons' projected positions, less the midpoint of the slots they used to
+    -- sit in. A hit still lands on the mon it is aimed at.
+    local a = OverworldBattle.ANCHOR
+    local dx = (shot.enemy[1] + shot.player[1]) / 2
+                 - (a.enemy[1] + a.player[1]) / 2
+    local dy = (shot.enemy[2] + shot.player[2]) / 2
+                 - (a.enemy[2] + a.player[2]) / 2
+    love.graphics.push()
+    love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
+    local ok, err = pcall(innerAnim, self, colorized)
+    love.graphics.pop()
+    if not ok then error(err, 0) end
+  end
+
+  -- Black glyphs on grass are not readable; over a frosted panel measured
+  -- dark they are not readable either, so they go white. Mapped rather than
+  -- rewritten: the HUD sets pure black for its text and nothing else, and in
+  -- the colorized pipeline this lands in the grayscale BG canvas, where
+  -- white IS shade 0 and the zone pass then colours it like every other
+  -- lightest-shade surface. One rule, both pipelines.
+  --
+  -- The HP bar is untouched: it is drawn in its own greens and reds, and
+  -- only an exactly-black set is remapped.
+  local innerHUDs = BattleState.drawHUDs
+  function BattleState:drawHUDs(slide)
+    if not (self.dramaticShapeShot and self.dramaticShapeDark) then
+      return innerHUDs(self, slide)
+    end
+    local battle = self
+    BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
+      innerHUDs(battle, slide)
+    end)
+  end
+
+  BattleState.dramaticShapeBattleHook = true
+end
+
+-- Whether each HUD block is on screen this frame.
+--
+-- READ-ONLY duplicates of drawHUDs' own two guards, because there is no seam
+-- that reports "the enemy HUD is up". A panel under a HUD that is not there
+-- would be a frosted slab floating in the arena, so it is worth mirroring;
+-- the worst a future engine change can do is show an empty one for a frame,
+-- never break a battle.
+function OverworldBattle.hudLive(battle, slide)
+  local enemy = battle.enemy and not battle.showEnemyTrainer
+                and not battle.enemySendingOut
+                and not battle:growInScale(battle.enemy) and slide == 0
+                and not battle.enemy.fainted
+  local player = battle.player and not (battle.safari or battle.demo)
+                 and not battle.showPlayerBack and slide == 0
+  return enemy and true or false, player and true or false
+end
+
+-- Lay the frosted glass down under whichever HUD is about to draw, and
+-- record which way the glyphs have to flip.
+function OverworldBattle.drawHudPanels(battle)
+  local shot = battle.dramaticShapeShot
+  battle.dramaticShapeDark = nil
+  if not shot then return end
+  local slide = (battle.introSlide or 0) * 4
+  local enemy, player = OverworldBattle.hudLive(battle, slide)
+  if not (enemy or player) then return end
+  local rect = OverworldBattle.HUD_RECT
+  local live = {}
+  if enemy then live.enemy = rect.enemy end
+  if player then live.player = rect.player end
+  local dark = BattleHud.verdict(live, shot)
+  battle.dramaticShapeDark = dark
+  for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
+end
+
+return OverworldBattle
diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua
index 6023738..7a22f0d 100644
--- a/lib/Voxel3D.lua
+++ b/lib/Voxel3D.lua
@@ -232,7 +232,15 @@ local SHADER = [[
 -- Each entry is nil = untried, false = unavailable.
 local shaders = { [false] = nil, [true] = nil }
 local activeShader = nil      -- the variant this pass bound
-local canvas, canvasW, canvasH = nil, 0, 0
+
+-- Scene canvases, one per NAMED SLOT. There are exactly two callers and
+-- they want different sizes -- the free-roam pass renders at the window's
+-- pixel dimensions, the overworld battle at the GB's 160x144 -- and a
+-- single cached canvas made every battle entry and exit reallocate one.
+-- A slot reallocates only when its OWN size changes, which is a window
+-- resize, so the pair is stable for a session.
+local slots = {}
+local canvas, canvasW, canvasH = nil, 0, 0   -- the slot this pass bound
 local active = false
 
 local IDENTITY = Mat4.identity()
@@ -312,9 +320,44 @@ end
 
 -- ---------------------------------------------------------------- camera --
 
+-- An explicit camera, replacing the orbit below for as long as it is set:
+-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil }.
+--
+-- The orbit is the free-roam camera and it is described entirely by ONE
+-- number, the pitch, because that is all a camera following the player over
+-- their own map ever needs. A staged shot -- the overworld battle's
+-- over-the-shoulder rig (see BattleCam) -- is a placed camera: it has a yaw,
+-- it does not sit above its focus, and its framing comes from the arena
+-- rather than from the view size. Rather than widen the orbit into
+-- something that could express both and be the wrong shape for each, a
+-- caller with a camera of its own simply hands it over.
+--
+-- Everything downstream is unchanged by this: the shader uniforms, project()
+-- and the overlay all read Voxel3D.vp / Voxel3D.eye, which are set the same
+-- way either way.
+Voxel3D.camera = nil
+
 -- View and projection for a `vw` x `vh` world-pixel view centred on
 -- (cx, cy) in world pixels. Returns the combined matrix.
 function Voxel3D.viewProjection(cx, cy, vw, vh)
+  local cam = Voxel3D.camera
+  if cam then
+    local eye, focus = cam.eye, cam.focus
+    Voxel3D.eye = eye
+    local dx = eye[1] - focus[1]
+    local dy = eye[2] - focus[2]
+    local dz = eye[3] - focus[3]
+    local dist = math.max(1, math.sqrt(dx * dx + dy * dy + dz * dz))
+    local proj = Mat4.perspective(cam.fov, vw / vh,
+                                  math.max(1, dist * 0.05), dist * 4 + 4096)
+    -- the same clip-space Y flip the orbit needs, for the same reason: we
+    -- bypass LOVE's transform_projection and canvas coordinates run Y down
+    proj = Mat4.mul(Mat4.scale(1, -1, 1), proj)
+    -- world up, so the horizon stays level -- a placed camera that rolled
+    -- with its own pitch would tip the whole arena
+    return Mat4.mul(proj, Mat4.lookAt(eye, focus, { 0, 1, 0 }))
+  end
+
   local a = Voxel.angle
   local focal = Voxel.FOCAL
   local dist = focal * vh
@@ -351,7 +394,9 @@ end
 -- `sky` is an optional {r, g, b, a} in 0..1 to clear the void to, for the
 -- pitch where the horizon is in frame (VoxelScene.skyFor). nil leaves the
 -- void transparent, which is what every rung below it wants.
-function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
+-- `slot` names which cached canvas to render into (see `slots` above);
+-- omitted is the free-roam world pass.
+function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
   -- the wireframe variant when the player has it on AND it built; either
   -- answer falls through to the plain scene rather than to no scene
   local grid = VoxelGrid.enabled()
@@ -360,12 +405,19 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
     grid, sh = false, Voxel3D.shader()
   end
   if not sh then return false end
-  if not canvas or canvasW ~= w or canvasH ~= h then
+  local name = slot or "world"
+  local held = slots[name]
+  if not (held and held.w == w and held.h == h) then
     local ok, c = pcall(love.graphics.newCanvas, w, h)
     if not ok then return false end
     c:setFilter("nearest", "nearest")
-    canvas, canvasW, canvasH = c, w, h
+    if held and held.canvas and held.canvas.release then
+      pcall(held.canvas.release, held.canvas)
+    end
+    held = { canvas = c, w = w, h = h }
+    slots[name] = held
   end
+  canvas, canvasW, canvasH = held.canvas, w, h
   -- a depth buffer is what makes occlusion real: walk behind a building and
   -- the building wins, with no y-sorting anywhere
   local ok = pcall(love.graphics.setCanvas,
@@ -411,8 +463,10 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky)
   pcall(sh.send, sh, "ghost", 0)
   pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR)
   -- the curved world bends about the camera's focus, so the horizon keeps
-  -- a fixed distance ahead of the player rather than sitting on the map
-  Voxel3D.curveK = WorldCurve.k(vh)
+  -- a fixed distance ahead of the player rather than sitting on the map.
+  -- A placed camera may decline it outright (Voxel3D.camera.curve = 0).
+  local placed = Voxel3D.camera
+  Voxel3D.curveK = (placed and placed.curve) or WorldCurve.k(vh)
   Voxel3D.curveX, Voxel3D.curveZ = cx, cy
   pcall(sh.send, sh, "curve", { cx, cy, Voxel3D.curveK })
   -- clip w at the focus point, the reference depth project() reports scale
@@ -658,6 +712,12 @@ end
 
 -- Drop the GPU objects (window resize, hot reload).
 function Voxel3D.invalidate()
+  for name, held in pairs(slots) do
+    if held.canvas and held.canvas.release then
+      pcall(held.canvas.release, held.canvas)
+    end
+    slots[name] = nil
+  end
   canvas, canvasW, canvasH = nil, 0, 0
   ShadowMap.invalidate()
 end
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index 5b025d4..b3d4d6c 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -84,13 +84,25 @@ local function skyStrength(angleRad)
   return t
 end
 
-local function skyFor(map)
-  if not (map and map.def and Map.isOutdoor(map.def)) then return nil end
-  local t = skyStrength(Voxel.angle)
-  if t <= 0 then return nil end
+-- One shade off the sky ramp, transformed by the display mode, as an
+-- {r, g, b, a} in 0..1. `shade` picks the rung (SKY_SHADE is the sky
+-- proper; 4 is its darkest, which is what an indoor void wants).
+function VoxelScene.skyShade(shade, alpha)
   local shades = PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES
-  local c = shades[SKY_SHADE] or SKY_SHADES[SKY_SHADE]
-  return { c[1] / 255, c[2] / 255, c[3] / 255, t }
+  local c = shades[shade] or SKY_SHADES[shade] or SKY_SHADES[SKY_SHADE]
+  return { c[1] / 255, c[2] / 255, c[3] / 255, alpha or 1 }
+end
+
+-- The sky `map` stands under at strength `t`, or nil where there is no sky
+-- to paint: indoors, or with the horizon out of frame.
+function VoxelScene.skyColor(map, t)
+  if not (map and map.def and Map.isOutdoor(map.def)) then return nil end
+  if not t or t <= 0 then return nil end
+  return VoxelScene.skyShade(SKY_SHADE, t)
+end
+
+local function skyFor(map)
+  return VoxelScene.skyColor(map, skyStrength(Voxel.angle))
 end
 
 VoxelScene._skyFor = skyFor           -- named for the suite
@@ -131,6 +143,9 @@ local function groundAt(map, cellX, cellY)
 end
 
 VoxelScene.YAW = YAW
+-- shared with the overworld battle, which stands its mons on map cells and
+-- needs the same answer about what height "the floor" is there
+VoxelScene.groundAt = groundAt
 
 -- Camera-ward pull distance for billboards (and the grass rows, which
 -- must keep their relative depth to feet): just enough that a leaned-back
diff --git a/main.lua b/main.lua
index 2d19fab..b77e555 100644
--- a/main.lua
+++ b/main.lua
@@ -77,6 +77,7 @@ local TiltShift = V.require("TiltShift")
 local ChunkMesher = V.require("ChunkMesher")
 local VoxelGrid = V.require("VoxelGrid")
 local WorldCurve = V.require("WorldCurve")
+local OverworldBattle = V.require("OverworldBattle")
 
 -- The last VOID FILL the terrain was meshed under; see the update hook.
 -- The scene canvas's size, in FRAMEBUFFER PIXELS.
@@ -145,6 +146,15 @@ mod.content.render_pipelines:register("voxel", {
   -- already there instead of a flat flash.
   update = function(dt, level)
     Voxel.update(dt, level)
+    -- The overworld battle rides this hook rather than owning a pipeline of
+    -- its own, because it owns no pass of the FRAME: it draws under a battle
+    -- screen the engine composites, which is not a stage the registry has.
+    -- What it needs is a tick that keeps running once the overworld stops
+    -- being the top state, and this is one -- Game:update calls
+    -- Pipelines.update unconditionally, so it survives the transition wipe
+    -- and the whole battle. Ahead of the active() gate below, because a 3D
+    -- battle does not require the free-roam mode to be switched on.
+    OverworldBattle.update(dt)
     -- VOID FILL picks the block the border ring is made of, and in this
     -- mode that ring is BAKED INTO THE MESH rather than drawn each frame.
     -- So the option has to reach the cache or nothing happens on screen
@@ -186,6 +196,7 @@ mod.content.render_pipelines:register("voxel", {
 
   invalidate = function()
     Voxel3D.invalidate()
+    OverworldBattle.invalidate()
     ChunkMesher.invalidate()   -- no map id = every cached mesh
   end,
 })
@@ -226,6 +237,9 @@ local SETTINGS = {
   { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
   { WorldCurve.setting,
     "Bend the world down over the horizon, Animal Crossing style." },
+  { OverworldBattle.setting,
+    "Fight on the map: the battle draws over the nearest clear ground, "
+    .. "shot over the shoulder with a slow parallax drift." },
 }
 
 local schema = {}
@@ -240,13 +254,14 @@ mod.options:define(schema)
 --   5  V-GRID   toggle the wireframe         (new)
 --   6  T-SHIFT  cycle the blur ladder        (was 9)
 --   7  V-CURVE  cycle the horizon bend       (new)
+--   8  3D-BTL   toggle overworld battles     (new)
 --
 -- Only 6 arrives by the documented route. Game:keypressed answers the
 -- engine's own display keys FIRST and returns -- 2 COLORS, 3 TILT, 4 ZOOM,
 -- 5 GBC FX -- and only then offers the key to Pipelines.hotkey, expressly
 -- so "a pipeline can never shadow one" (Schemas, render_pipelines.hotkey).
--- 3 and 5 are two of those, and 7 belongs to a pair of plain mod settings
--- that own no pass and so have no registry to claim a key from at all.
+-- 3 and 5 are two of those, and 7 and 8 belong to plain mod settings that
+-- own no pass and so have no registry to claim a key from at all.
 --
 -- So this wraps Game:keypressed. It is the invasive option and it is the
 -- only one: polling the keyboard in update() would fire alongside the
@@ -268,6 +283,7 @@ local HOTKEYS = {
   ["6"] = "pipeline",           -- tiltshift, likewise
   ["5"] = VoxelGrid.setting,
   ["7"] = WorldCurve.setting,
+  ["8"] = OverworldBattle.setting,
 }
 
 do
@@ -304,10 +320,14 @@ do
           return
         end
       elseif Pipelines.canToggle("voxel", top, self.overworld) then
-        -- Both settings parameterise the voxel pass, so they answer to the
-        -- same free-roam gate it does -- borrowed from the registry rather
-        -- than restated, so a press mid-warp or mid-cutscene is refused for
-        -- the wireframe exactly when it would be for the mode itself.
+        -- All three answer to the voxel pass's own free-roam gate --
+        -- borrowed from the registry rather than restated, so a press
+        -- mid-warp or mid-cutscene is refused for the wireframe exactly when
+        -- it would be for the mode itself. Two of them parameterise that
+        -- pass; the third (3D-BTL) decides what a battle is drawn over, and
+        -- wants the same gate for a different reason: the answer is read
+        -- when the fight starts, so flipping it from inside one would be a
+        -- switch that appeared to do nothing.
         claim:cycle(self)
         return
       end
@@ -416,7 +436,52 @@ mod.events:on("map.reloaded", function(payload)
   if mapId then ChunkMesher.invalidate(mapId) end
 end)
 
-mod.exports.version = "1.0.6"
+-- ------- battles on the map
+--
+-- The wraps this needs -- OverworldState:pushBattle, BattleState:draw and
+-- BattleState:drawHUDs -- all live in lib/OverworldBattle.lua, which is
+-- where the reasoning for each one is written down. Installed once, here,
+-- so this file keeps naming every engine seam the mod touches.
+OverworldBattle.install()
+
+-- The overworld's own pushBattle is the choke point for a wild encounter or
+-- a trainer, and it is wrapped. A battle that arrives some other way -- a
+-- link battle, a script pushing a BattleState directly -- reaches this
+-- instead, which stages the arena from wherever the player is standing.
+-- Nothing visible is lost by being late: the cull only has to beat the
+-- battle screen, and the wipe those battles skip is where it would have
+-- shown.
+mod.events:on("battle.started", function(payload)
+  OverworldBattle.ensure(payload and payload.battle)
+end)
+
+-- Both mons face the camera, so the player's side wants its FRONT pic where
+-- the battle screen would have used the back one. The engine's own
+-- pokemon.sprite hook is the seam for exactly this: it is asked for every
+-- battle pic with the side it is resolving, so swapping one side's answer
+-- needs no battle code at all -- and every path that builds a battler goes
+-- through it, including a Transform mid-fight.
+--
+-- next() first, so a sprite-replacing mod loaded before this one still gets
+-- the last word on WHICH art is used; this only changes which SIDE is asked
+-- for.
+mod.hooks:wrap("pokemon.sprite", function(next, path, ctx)
+  local out = next(path, ctx)
+  if not (ctx and ctx.kind == "battle" and ctx.side == "back") then
+    return out
+  end
+  if not OverworldBattle.wantsFront() then return out end
+  local def = ctx.data and ctx.data.pokemon and ctx.data.pokemon[ctx.species]
+  return (def and def.spriteFront) or out
+end)
+
+-- Every ending path emits this, including a battle skipped before it drew,
+-- so this is where the map's cast comes back.
+mod.events:on("battle.ended", function()
+  OverworldBattle.finish()
+end)
+
+mod.exports.version = "1.1.0"
 -- exposed so a companion mod can pin its own tiles' shapes or read the
 -- camera without reaching into this mod's file layout
 mod.exports.lib = V
diff --git a/manifest.json b/manifest.json
index 89de570..3ac3b2e 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
 {
   "id": "DRAMATIC_SHAPE",
   "name": "Dramatic Shape Voxel Mod",
-  "version": "1.0.6",
+  "version": "1.1.0",
   "api": 2,
   "entry": "main.lua",
   "profile": "content",
@@ -13,5 +13,5 @@
   "conflicts": [],
   "permissions": ["engine_internals"],
   "affects_link": false,
-  "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass. Registers two render pipelines and claims hotkeys 3, 5, 6 and 7 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only."
+  "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Registers two render pipelines and claims hotkeys 3, 5, 6, 7 and 8 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands."
 }
diff --git a/mod.card b/mod.card
index cac50a1..762df5b 100644
--- a/mod.card
+++ b/mod.card
@@ -1,14 +1,18 @@
 -- Sharing metadata (25-community-and-ecosystem.md 3.2).  Read by tooling
 -- and the manager detail pane; never by the loader's merge.
 return {
-  summary = "The overworld as a 3D diorama: extruded terrain, real occlusion, voxel characters, tilt-shift miniature blur.",
+  summary = "The overworld as a 3D diorama, and battles fought on it: real occlusion, tilt-shift, over-the-shoulder fights.",
   author = "DramaticShape",
   contact = "https://github.com/DramaticShape/DRAMATIC_SHAPE",
-  tags = { "graphics", "3d", "voxel", "render-pipeline", "presentation" },
+  tags = { "graphics", "3d", "voxel", "render-pipeline", "presentation",
+           "battle" },
   differences = {
     changed = {
       "with VOXEL on, the overworld draws as 3D geometry instead of flat tiles",
       "occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them",
+      "with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field",
+      "the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map",
+      "a battle's letterbox voids go black rather than white, because the battle canvas is no longer white",
       "VOXEL and the engine's TILT are mutually exclusive -- turning one on switches the other off",
       "hotkeys 3 and 5 are taken over from the engine's TILT and GBC FX; both remain on the OPTIONS menu",
       "the VOXEL key (3) turns TILT and GBC FX off on every press -- both fight the diorama, and 3 is now the only key that reaches either",
@@ -17,12 +21,17 @@ return {
       "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)",
       "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",
+      "3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map",
+      "an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp",
       "a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode",
       "a hand-authored tile shape profile (data/voxel_heights.lua) a mod can extend",
     },
     known = {
-      "needs shader and depth-canvas support; without them the row still cycles but the world stays 2D",
-      "battles, menus and cutscenes are unaffected -- the mode only draws the free-roam overworld",
+      "needs shader and depth-canvas support; without them the rows still cycle but the world stays 2D and battles draw plainly",
+      "a map with no 3x6 clearing falls back to a 1x4 one, and a map with neither draws the plain battle screen",
+      "the arena is where the CAMERA goes -- nobody is moved, so a fight staged across the map is a shot of that ground, not a trip to it",
+      "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",
     },
   },
diff --git a/tests/arena_pick.lua b/tests/arena_pick.lua
new file mode 100644
index 0000000..d8af2d8
--- /dev/null
+++ b/tests/arena_pick.lua
@@ -0,0 +1,173 @@
+-- Driver: choose and photograph one battle arena per map.
+--
+-- data/battle_arenas.lua holds one authored spot per area. This is what
+-- authors it: for every map a battle can happen on, it asks BattleArena for
+-- the arena nearest the map's middle that its clearance test says both mons
+-- can be SEEN in, stages a real battle there, and takes one screenshot.
+--
+-- Two outputs. A `PICK` line per map, ready to paste into the data file, and
+-- a PNG per map to look at -- because the clearance test answers a geometry
+-- question ("nothing tall on the sightline") and the actual question is
+-- whether the picture reads. Grass and flowers around a mon's feet are fine
+-- and wanted; a body cut in half by a wall is not, and only an eye catches
+-- the difference.
+--
+--   SHOT_DIR=.scratchpad/arenas ARENA_FROM=1 ARENA_COUNT=20 \
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
+--
+-- ARENA_FROM / ARENA_COUNT slice the map list so several runs can share the
+-- work; ARENA_MAPS=ID,ID,... does an explicit set instead. SHOT_DIR must
+-- already exist -- the capture writes with io.open, which does not create
+-- directories.
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local DIR = os.getenv("SHOT_DIR") or ".scratchpad/arenas"
+  local Pokemon = require("src.pokemon.Pokemon")
+  local BattleState = require("src.battle.BattleState")
+
+  game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
+  game.save.player.name = "RED"
+
+  local exports = game.mods and game.mods.exports
+  local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
+  if not lib then
+    U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
+    return
+  end
+  local Arena = lib.require("BattleArena")
+  local Battles = lib.require("OverworldBattle")
+
+  -- Which maps a battle can actually happen on: anything with a wild
+  -- encounter table or an object that fights. Everything else -- a shop
+  -- floor, a stairwell, a bedroom -- would be authoring a spot for a fight
+  -- that never happens there.
+  --
+  -- The encounter tables are their OWN registry keyed by map id, not a field
+  -- on the map record. An earlier cut of this read `def.encounters`, which is
+  -- always nil -- so the list silently narrowed to trainer maps only and
+  -- every route, cave and Safari zone fell out of it. Nothing failed; the
+  -- tool just quietly stopped offering to author the places wild battles
+  -- actually happen.
+  local function fights(id, def)
+    local enc = game.data.encounters and game.data.encounters[id]
+    if enc then
+      for _, kind in ipairs({ "grass", "water" }) do
+        local t = enc[kind]
+        if t and (t.rate or 0) > 0 and t.slots and t.slots[1] then
+          return true
+        end
+      end
+    end
+    for _, obj in ipairs(def.objects or {}) do
+      if obj.trainer or obj.trainerClass or obj.species then return true end
+    end
+    return false
+  end
+
+  local ids = {}
+  local explicit = os.getenv("ARENA_MAPS")
+  if explicit and explicit ~= "" then
+    for id in explicit:gmatch("[^,%s]+") do ids[#ids + 1] = id end
+  else
+    for id, def in pairs(game.data.maps) do
+      if type(id) == "string" and type(def) == "table" and fights(id, def) then
+        ids[#ids + 1] = id
+      end
+    end
+    table.sort(ids)
+  end
+
+  local from = tonumber(os.getenv("ARENA_FROM") or "") or 1
+  local count = tonumber(os.getenv("ARENA_COUNT") or "") or #ids
+  U.log(("%d battle maps; doing %d..%d"):format(#ids, from,
+                                                math.min(#ids, from + count - 1)))
+  -- ARENA_COUNT=0 lists what WOULD be done and stops. The set this filter
+  -- picks is the authoritative answer to "which areas need an arena", so it
+  -- has to be readable without sitting through a capture -- that is how the
+  -- bug above went unnoticed.
+  if count <= 0 then
+    for i = 1, #ids, 8 do
+      U.log("LIST  " .. table.concat(ids, ",", i, math.min(#ids, i + 7)))
+    end
+    U.log("done -- listing only")
+    return
+  end
+
+  for i = from, math.min(#ids, from + count - 1) do
+    local id = ids[i]
+    local def = game.data.maps[id]
+    local cx = math.floor(def.width)          -- the middle, in cells
+    local cy = math.floor(def.height)
+
+    -- the pick is made against the MAP, before anything is staged, so a map
+    -- with no clear arena at all is reported rather than quietly given a bad
+    -- one
+    local ok, err = pcall(function()
+      U.teleport(game, id, 1, 1, "down")
+    end)
+    if not ok then
+      U.log(("SKIP %s -- could not enter (%s)"):format(id, tostring(err)))
+    else
+      local map = game.overworld.map
+      local clear, any
+      -- ARENA_AT=x,y[,shape] photographs one specific spot instead of the
+      -- picked one, which is how a map the automatic choice got wrong is
+      -- re-aimed by hand and checked in the same loop.
+      -- ARENA_MAP=OTHER_ID stages on another floor of the same cave or
+      -- building, for a map that has nowhere of its own to put a fight
+      local at = os.getenv("ARENA_AT")
+      local onMap = os.getenv("ARENA_MAP")
+      if at and at ~= "" and (explicit and explicit ~= "") then
+        local ax, ay, ashape = at:match("^(%-?%d+)%s*,%s*(%-?%d+)%s*,?%s*(%a*)$")
+        if ax then
+          -- forced through the authored-entry seam, so what gets staged is
+          -- exactly what was asked for rather than whatever the search
+          -- would have picked from the player's cell
+          local onCam = os.getenv("ARENA_CAM")
+          Arena.setOverride(id, {
+            x = tonumber(ax), y = tonumber(ay),
+            shape = (ashape ~= "" and ashape) or "wide",
+            map = (onMap ~= "" and onMap) or nil,
+            cam = (onCam and onCam ~= "" and onCam) or nil,
+          })
+          any = Arena.find(map, 0, 0, false)
+          clear = any and Arena.clearance(any.map or map, any) or false
+        end
+      end
+      if not any then
+        clear = Arena.search(map, cx, cy, false, true)
+        any = clear or Arena.search(map, cx, cy, false)
+      end
+      if not any then
+        U.log(("NONE %s -- no arena of either shape"):format(id))
+      else
+        local onOther = any.map and any.map.id ~= id and any.map.id or nil
+        U.log(("PICK   [%q] = { %sx = %d, y = %d, shape = %q%s },%s")
+              :format(id, onOther and ("map = %q, "):format(onOther) or "",
+                      any.x, any.y, any.shape,
+                      any.cam and (", cam = %q"):format(any.cam) or "",
+                      clear and "" or "   -- OBSTRUCTED: no clear spot"))
+        -- stand the player on the arena so the staged battle uses it, then
+        -- fight something there and photograph the result
+        game.overworld.player.cellX = any.playerCell[1]
+        game.overworld.player.cellY = any.playerCell[2]
+        U.wait(90)                            -- let the meshes land
+        local battle = BattleState.newWild(game, "NIDORINO", 20)
+        battle.onFinish = function() end
+        game.overworld:pushBattle(battle)
+        U.wait(70)
+        for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
+        local got = Battles.arena()
+        U.log(("SHOT   %s at %s,%s"):format(id,
+              tostring(got and got.x), tostring(got and got.y)))
+        U.shot(game, ("%s/%s.png"):format(DIR, id:lower()))
+        while game.stack:top() and game.stack:top() ~= game.overworld do
+          game.stack:pop()
+        end
+        U.wait(6)
+      end
+    end
+  end
+
+  U.log("done -- " .. DIR)
+end
diff --git a/tests/battle_shots.lua b/tests/battle_shots.lua
new file mode 100644
index 0000000..af8ae8b
--- /dev/null
+++ b/tests/battle_shots.lua
@@ -0,0 +1,130 @@
+-- Driver: screenshot an overworld battle against eight random trainers.
+--
+-- The mode's whole claim is that a fight reads as happening on the map, so
+-- what has to be looked at is a spread of maps: open route, town, forest,
+-- cave, gym floor. Each one gets the same three beats -- the menu (both HUD
+-- panels up over whatever ground they landed on), a move animation, and the
+-- frame a few seconds later, which is where the parallax drift shows.
+--
+-- The eight are drawn with a fixed seed, so a re-run after a tweak produces
+-- the same eight battles and the shots compare directly.
+--
+--   SHOT_DIR=.scratchpad POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/battle_shots.lua love .
+--
+-- SHOT_DIR must already exist: the capture writes with io.open, which does
+-- not create directories.
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
+  local Pokemon = require("src.pokemon.Pokemon")
+  local BattleState = require("src.battle.BattleState")
+
+  local SEED = tonumber(os.getenv("SHOT_SEED") or "") or 20260727
+  love.math.setRandomSeed(SEED)
+
+  -- Somebody to fight with. Two very different back pics, so a pin that is
+  -- wrong for one silhouette and right for another cannot hide.
+  game.save.party = {
+    Pokemon.new(game.data, "CHARIZARD", 45),
+    Pokemon.new(game.data, "PIKACHU", 40),
+  }
+  game.save.player.name = "RED"
+
+  -- Where to stage them: outdoor, wooded, urban, underground, indoor. Any id
+  -- the merged dataset does not have is dropped rather than guessed at.
+  local PLACES = {
+    { "ROUTE_1", 5, 8 },
+    { "VIRIDIAN_FOREST", 12, 20 },
+    { "PALLET_TOWN", 5, 6 },
+    { "MT_MOON_1F", 12, 12 },
+    { "CERULEAN_CITY", 10, 12 },
+    { "ROUTE_25", 12, 5 },
+    { "PEWTER_GYM", 4, 8 },
+    { "ROUTE_4", 10, 5 },
+    { "VIRIDIAN_CITY", 20, 20 },
+    { "ROUTE_3", 10, 5 },
+  }
+  local places = {}
+  for _, p in ipairs(PLACES) do
+    if game.data.maps[p[1]] then places[#places + 1] = p end
+  end
+
+  -- every trainer class the merged data carries, in a stable order so the
+  -- seed picks the same ones every run
+  local classes = {}
+  for id, rec in pairs(game.data.trainers) do
+    if type(id) == "string" and id:sub(1, 1) ~= "_"
+       and type(rec) == "table" and rec.parties and rec.parties[1] then
+      classes[#classes + 1] = id
+    end
+  end
+  table.sort(classes)
+  U.log(("%d trainer classes, %d places, seed %d")
+        :format(#classes, #places, SEED))
+
+  local Battles = nil
+  local exports = game.mods and game.mods.exports
+  local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
+  if lib then Battles = lib.require("OverworldBattle") end
+  if not Battles then
+    U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
+  end
+
+  local function label(place, class)
+    local arena = Battles and Battles.arena()
+    if not arena then
+      return ("%s / %s -- NO ARENA (plain battle screen)")
+             :format(place[1], class)
+    end
+    return ("%s / %s -- %s arena at cell %d,%d; enemy %d,%d player %d,%d")
+           :format(place[1], class, arena.shape, arena.x, arena.y,
+                   arena.enemyCell[1], arena.enemyCell[2],
+                   arena.playerCell[1], arena.playerCell[2])
+  end
+
+  for i = 1, 8 do
+    -- the TRAINERS are the random part; the places are walked in order, so
+    -- one run covers open route, town, forest, cave and gym floor rather
+    -- than landing on the same meadow eight times
+    local place = places[(i - 1) % #places + 1]
+    local class = classes[love.math.random(#classes)]
+    local rec = game.data.trainers[class]
+    local partyIndex = love.math.random(#rec.parties)
+    local tag = ("%02d_%s_%s"):format(i, place[1]:lower(), class:lower())
+
+    U.teleport(game, place[1], place[2], place[3], "down")
+    -- let the neighbourhood's meshes land before the fight starts, so the
+    -- first battle frame is not the flat fallback
+    U.wait(90)
+
+    local battle = BattleState.newTrainer(game, class, partyIndex)
+    battle.onFinish = function() end
+    game.overworld:pushBattle(battle)
+
+    -- the wipe, then the send-out chatter
+    U.wait(70)
+    for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
+    U.log(label(place, class))
+    U.shot(game, ("%s/%s_1_menu.png"):format(DIR, tag))
+
+    -- FIGHT -> first move -> the animation
+    U.tap(game, "a")
+    U.wait(12)
+    U.tap(game, "a")
+    U.wait(24)
+    U.shot(game, ("%s/%s_2_anim.png"):format(DIR, tag))
+
+    -- and a while later, where the drift has moved the world under them
+    U.wait(240)
+    U.shot(game, ("%s/%s_3_drift.png"):format(DIR, tag))
+
+    -- out of the battle and on to the next: pop whatever is above the
+    -- overworld rather than trying to play the fight to its end
+    while game.stack:top() and game.stack:top() ~= game.overworld do
+      game.stack:pop()
+    end
+    U.wait(10)
+  end
+
+  U.log("done -- " .. DIR)
+end
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 9612840..006a23e 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -10,7 +10,12 @@ local T = require("tests.modkit")
 local Pipelines = require("src.render.Pipelines")
 
 local Data = T.fixtures.load()
-local run = T.sdk.loadMod("mods/DRAMATIC_SHAPE", { data = Data })
+-- DS_MOD_PATH lets the suite run against a copy of the mod. The game holds
+-- an open handle on the live directory while it is running, and on Windows
+-- that is enough to make the headless loader's directory probe fail, so a
+-- run alongside a live session points at a copy instead.
+local MOD_PATH = os.getenv("DS_MOD_PATH") or "mods/DramaticShapeVoxelMod"
+local run = T.sdk.loadMod(MOD_PATH, { data = Data })
 
 T.eq(#run.errors, 0,
   "DRAMATIC_SHAPE loads clean: " .. table.concat(run.errors, "; "))
@@ -100,19 +105,25 @@ T.eq(byLabel.VOXEL.value(), "15", "the row renders the current rung's label")
 
 -- ------- this mod's own settings
 --
--- Neither is a pipeline (they parameterise the voxel pass rather than
--- owning one), so they reach the same menu through the ui.options.rows
--- hook, and store themselves where the mod manager's settings page looks.
+-- None of them is a pipeline -- two parameterise the voxel pass rather than
+-- owning one, and the third decides what a BATTLE is drawn over, which is
+-- not a stage the registry has -- so they reach the same menu through the
+-- ui.options.rows hook, and store themselves where the mod manager's
+-- settings page looks.
 
 local Runtime = require("src.mods.Runtime")
 local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
                                { data = Data }, { { id = "text_speed" } })
-T.eq(#hookedRows, 3, "the options hook added a row per setting")
-local grid, curve = hookedRows[2], hookedRows[3]
+T.eq(#hookedRows, 4, "the options hook added a row per setting")
+local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4]
 T.eq(grid.label, "V-GRID", "the grid row carries its label")
 T.eq(grid.value(), "OFF", "the grid starts off")
 T.eq(curve.label, "V-CURVE", "the curve row carries its label")
 T.eq(curve.value(), "OFF", "the curve starts off")
+T.eq(battles.label, "3D-BTL", "the overworld-battle row carries its label")
+T.eq(battles.value(), "ON",
+  "overworld battles are on by default -- the mode's headline is the world "
+  .. "in 3D, and a battle is where the player spends half the game")
 
 -- stepping writes through to the one place both rows read
 local settingGame = { save = { options = {} }, mods = { modOptions = {} } }
@@ -694,13 +705,14 @@ T.eq(modeColors(nil), nil, "and a pipeline given no paletteFor at all is safe")
 --   5  V-GRID   toggle the wireframe
 --   6  T-SHIFT  cycle the blur ladder
 --   7  V-CURVE  cycle the horizon bend
+--   8  3D-BTL   toggle overworld battles
 --
 -- Only 6 reaches the pipeline registry the documented way. Game:keypressed
 -- answers the engine's own display keys first and returns -- 3 is TILT and
--- 5 is GBC FX -- expressly so a pipeline cannot shadow one, and 7 belongs
--- to settings that own no pass and so have no registry to claim from. The
--- mod therefore wraps Game:keypressed, and these pin what that wrapper is
--- allowed to take.
+-- 5 is GBC FX -- expressly so a pipeline cannot shadow one, and 7 and 8
+-- belong to settings that own no pass and so have no registry to claim
+-- from. The mod therefore wraps Game:keypressed, and these pin what that
+-- wrapper is allowed to take.
 
 local Game = require("src.core.Game")
 Pipelines.reset()
@@ -739,6 +751,13 @@ local curveBefore = Curve.setting:get()
 Game.keypressed(keyGame, "7")
 T.neq(Curve.setting:get(), curveBefore, "7 cycles V-CURVE")
 
+local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
+T.eq(Battles.setting:get(), true, "3D-BTL starts on")
+Game.keypressed(keyGame, "8")
+T.eq(Battles.setting:get(), false, "8 toggles overworld battles off")
+Game.keypressed(keyGame, "8")
+T.eq(Battles.setting:get(), true, "and back on")
+
 -- 3 also clears the two engine modes it displaced. Without this a player
 -- who left TILT or GBC FX on before enabling the mod has no key left to
 -- turn them off with, and both fight the diorama -- TILT is the flat fake
@@ -890,6 +909,369 @@ T.check(skyRGB("gbc_inv")[3] ~= blue[3],
 
 Voxel.angle = 0
 
+-- ------- overworld battles: where the fight is staged
+--
+-- The arena search is pure map arithmetic, so it is driven here against
+-- hand-drawn maps rather than a fixture: the shape it looks for, the order
+-- it relaxes in, and what it refuses are all things a picture can state
+-- exactly.
+--
+-- The stub answers the small surface BattleArena asks a Map for. `rows` are
+-- strings, one per cell row, "." open and anything else solid; "w" is water
+-- (open to a surfer only) and "d" a warp tile.
+
+local BattleArena = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleArena")
+
+local function stubMap(rows)
+  local at = function(cx, cy)
+    local row = rows[cy + 1]
+    return row and row:sub(cx + 1, cx + 1) or "#"
+  end
+  return {
+    widthCells = #rows[1],
+    heightCells = #rows,
+    inBounds = function(_, cx, cy)
+      return cx >= 0 and cy >= 0 and cx < #rows[1] and cy < #rows
+    end,
+    warpAtCell = function() return nil end,
+    isWarpTileCell = function(_, cx, cy) return at(cx, cy) == "d" end,
+    isWalkableCell = function(_, cx, cy) return at(cx, cy) == "." end,
+    isWaterCell = function(_, cx, cy) return at(cx, cy) == "w" end,
+    isGrassCell = function(_, cx, cy) return at(cx, cy) == "g" end,
+  }
+end
+
+-- a field with room for the wide arena on its right-hand side only
+local field = stubMap({
+  "##########",
+  "#....#####",
+  "#....#####",
+  "#....#####",
+  "#....#####",
+  "#....#####",
+  "#....#####",
+  "##########",
+})
+
+local arena = BattleArena.find(field, 2, 3, false)
+T.check(arena ~= nil, "a field with a 3x6 clearing has an arena")
+T.eq(arena.shape, "wide", "and it is the wide shape, not the fallback")
+T.eq(arena.w, 3, "the wide arena is three cells across")
+T.eq(arena.h, 6, "and six deep")
+
+-- the two mons stand three cells apart down the middle column, which is
+-- what the picture in BattleArena says and what the camera is built around
+T.eq(arena.enemyCell[1], arena.playerCell[1],
+  "both mons stand in the arena's middle column")
+T.eq(arena.playerCell[2] - arena.enemyCell[2], 3,
+  "with three cells of ground between them")
+T.check(arena.enemyCell[2] < arena.playerCell[2],
+  "the enemy is the NORTH one -- the far end from a camera parked south")
+
+-- every cell of the shape is open, apron included: that one-cell margin is
+-- the difference between a staged shot and a fight in a doorway
+for cy = arena.y, arena.y + arena.h - 1 do
+  for cx = arena.x, arena.x + arena.w - 1 do
+    T.check(field:isWalkableCell(cx, cy),
+      ("arena cell (%d,%d) is open ground"):format(cx, cy))
+  end
+end
+
+-- world-pixel centres, which is the unit everything downstream works in
+T.eq(arena.player[1], arena.playerCell[1] * 16 + 8,
+  "the player's mark is the centre of its cell in world pixels")
+T.eq(arena.mid[2], (arena.enemy[2] + arena.player[2]) / 2,
+  "and the midpoint is halfway between the two")
+
+-- nearest, not first found: two clearings, and the one under the player wins.
+-- Wide enough that the battle camera -- which stands a few cells east and
+-- south of whatever it is aimed at -- is over real ground for BOTH of them,
+-- so this measures proximity rather than the clearance preference below.
+local twin = stubMap({
+  "########################",
+  "#.##########.###########",
+  "#.##########.###########",
+  "#.##########.###########",
+  "#.##########.###########",
+  "########################",
+  "########################",
+  "########################",
+  "########################",
+  "########################",
+})
+local near = BattleArena.find(twin, 12, 3, false)
+T.eq(near.shape, "narrow", "no 3x6 anywhere, so the search relaxes")
+T.eq(near.x, 12, "and takes the corridor the player is standing in")
+T.eq(BattleArena.find(twin, 1, 3, false).x, 1,
+  "the same map from the other side picks the other one")
+
+-- the wide shape wins even when a narrow one is closer: it is the shot this
+-- mode is framed for, so proximity does not get to overrule it
+local both = stubMap({
+  "#.#########",
+  "#.####...##",
+  "#.####...##",
+  "#.####...##",
+  "######...##",
+  "######...##",
+  "######...##",
+})
+T.eq(BattleArena.find(both, 1, 1, false).shape, "wide",
+  "a wide arena across the map beats a narrow one underfoot")
+
+-- water is ground for a surfer and nothing at all for anyone else
+local sea = stubMap({
+  "wwwwww",
+  "wwwwww",
+  "wwwwww",
+  "wwwwww",
+  "wwwwww",
+  "wwwwww",
+})
+T.eq(BattleArena.find(sea, 2, 2, false), nil,
+  "open water is not open ground to someone walking")
+T.check(BattleArena.find(sea, 2, 2, true) ~= nil,
+  "but it is to a surfer, who is standing on it")
+
+-- tall grass is walkable and is still not a stage: it is knee-high geometry
+-- standing between a nearly-level camera and the mon behind it
+local meadow = stubMap({
+  "########",
+  "#ggg..g#",
+  "#ggg..g#",
+  "#ggg..g#",
+  "#ggg..g#",
+  "#ggg..g#",
+  "#ggg..g#",
+  "########",
+})
+local mown = BattleArena.find(meadow, 2, 3, false)
+T.check(mown ~= nil, "a meadow with a bare strip still has an arena")
+for cy = mown.y, mown.y + mown.h - 1 do
+  for cx = mown.x, mown.x + mown.w - 1 do
+    T.check(not meadow:isGrassCell(cx, cy),
+      ("no cell of the arena is tall grass (%d,%d)"):format(cx, cy))
+  end
+end
+T.eq(BattleArena.find(stubMap({
+  "#####", "#ggg#", "#ggg#", "#ggg#", "#ggg#", "#ggg#", "#ggg#", "#####",
+}), 2, 3, false), nil,
+  "a map that is nothing but grass has nowhere to stand a fight")
+
+-- a doormat is walkable and is still not a stage
+local hall = stubMap({
+  "######",
+  "#..d.#",
+  "#....#",
+  "#....#",
+  "#....#",
+  "######",
+})
+local halled = BattleArena.find(hall, 2, 3, false)
+T.check(halled == nil or halled.shape == "narrow",
+  "a warp tile is excluded, so the room's only 3x6 does not qualify")
+
+T.eq(BattleArena.find(stubMap({ "###", "###" }), 1, 1, false), nil,
+  "a map with no room at all yields no arena, and the battle draws plainly")
+
+-- an authored refusal is honoured over the search: a map looked at and found
+-- to have nowhere a fight reads has to be able to say so, or the fallback
+-- goes and finds one of the spots that were already rejected by eye
+local roomy = stubMap({
+  "#####", "#...#", "#...#", "#...#", "#...#", "#...#", "#...#", "#####",
+})
+roomy.id = "TEST_REFUSED"
+T.check(BattleArena.find(roomy, 2, 3, false) ~= nil,
+  "a roomy map finds an arena by search when nothing is authored")
+BattleArena.setOverride("TEST_REFUSED", false)
+T.eq(BattleArena.find(roomy, 2, 3, false), nil,
+  "an authored false refuses the map outright, search and all")
+BattleArena.setOverride("TEST_REFUSED", nil)
+T.check(BattleArena.find(roomy, 2, 3, false) ~= nil,
+  "and dropping the refusal restores the search")
+
+-- ------- overworld battles: the over-the-shoulder framing
+--
+-- The claim the whole shot rests on. The two pics are PINNED to their cells,
+-- so the rig has to put those two patches of ground exactly where the GB's
+-- own battle screen puts its two pics -- the player's low and left at
+-- (40, 96), the enemy's high and right at (124, 56). Reprojected through the
+-- real camera rather than asserted about the constants, so the day someone
+-- retunes the rig this either still lands or says so.
+
+local BattleCam = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleCam")
+local BattleScene = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleScene")
+local Voxel3Dcam = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
+
+-- where a world point lands in the 160x144 frame, or nil behind the camera
+local function project(cam, point, w, h, fov)
+  local saved = cam.fov
+  if fov then cam.fov = fov end
+  Voxel3Dcam.camera = cam
+  local m = Voxel3Dcam.viewProjection(0, 0, w or 160, h or 144)
+  Voxel3Dcam.camera = nil
+  cam.fov = saved
+  local x = m[1] * point[1] + m[2] * 0 + m[3] * point[2] + m[4]
+  local y = m[5] * point[1] + m[6] * 0 + m[7] * point[2] + m[8]
+  local cw = m[13] * point[1] + m[14] * 0 + m[15] * point[2] + m[16]
+  if cw <= 1e-6 then return nil end
+  return (x / cw * 0.5 + 0.5) * (w or 160), (y / cw * 0.5 + 0.5) * (h or 144)
+end
+
+BattleCam.reset()
+local shot = BattleArena.find(field, 2, 3, false)
+local rig, pitch = BattleCam.rig(shot, 0)
+
+local px, py = project(rig, shot.player)
+local ex, ey = project(rig, shot.enemy)
+T.check(px ~= nil and ex ~= nil, "both marks are in front of the camera")
+T.check(math.abs(px - 26) < 1 and math.abs(py - 96) < 1,
+  ("the player's cell projects onto its pic's feet at (26, 96): got "
+   .. "(%.2f, %.2f)"):format(px, py))
+T.check(math.abs(ex - 124) < 1 and math.abs(ey - 56) < 1,
+  ("the enemy's cell projects onto its pic's feet at (124, 56): got "
+   .. "(%.2f, %.2f)"):format(ex, ey))
+T.check(px < ex, "which puts the player's mon LEFT of the enemy's")
+T.check(py > ey, "and lower in the frame -- nearer the camera")
+
+-- low and long: the eye is near the floor looking almost along it, which is
+-- what a 56-pixel sprite standing on a 16-pixel tile costs
+T.check(pitch > math.rad(60) and pitch < math.rad(85),
+  "the rig watches the arena from near ground level")
+
+-- ------- a mon covers its own square
+--
+-- The pics are drawn at INTEGER scales -- 56 pixels for a front pic at 1x, 64
+-- for a back pic at 2x -- so the only way a mon can stand in one overworld
+-- square is for the camera to make that square that big. This is the pair of
+-- equations the default rig was solved against alongside the two anchors, and
+-- it is the one that sets how far away the camera has to be.
+local function span(cam, point)
+  local a = project(cam, { point[1] - 8, point[2] })
+  local b = project(cam, { point[1] + 8, point[2] })
+  return math.abs(b - a)
+end
+
+T.check(math.abs(span(rig, shot.player) - 64) < 4,
+  ("the player's square is a back pic wide (64px at 2x): got %.2f")
+  :format(span(rig, shot.player)))
+T.check(math.abs(span(rig, shot.enemy) - 56) < 4,
+  ("the enemy's square is a front pic wide (56px at 1x): got %.2f")
+  :format(span(rig, shot.enemy)))
+
+-- ------- the close rig, for rooms the default cannot stand back from
+--
+-- Five blocks is further than a gym is wide, so on one the default eye lands
+-- outside the map and the border ring crosses the near mon. An arena asks for
+-- the short rig by name, and the two things that have to hold are that it
+-- really is close enough to sit in a room, and that it frames the SAME shot
+-- -- both marks still on their anchors -- so swapping rigs changes the lens
+-- and nothing about the composition.
+local function eyeDistance(cam)
+  local dx = cam.eye[1] - cam.focus[1]
+  local dy = cam.eye[2] - cam.focus[2]
+  local dz = cam.eye[3] - cam.focus[3]
+  return math.sqrt(dx * dx + dy * dy + dz * dz)
+end
+
+T.check(eyeDistance(rig) > 120,
+  "the default long lens stands well back -- that is what sizes the mons")
+
+BattleCam.reset()
+local snug = { mid = shot.mid, cam = "wide" }
+local closeRig = BattleCam.rig(snug, 0)
+local cd = eyeDistance(closeRig)
+T.check(cd < 80,
+  ("the wide lens is within five cells, so it fits inside a gym: got %.1f")
+  :format(cd))
+T.check(cd < eyeDistance(rig), "and is nearer than the default")
+
+local cpx, cpy = project(closeRig, shot.player)
+local cex, cey = project(closeRig, shot.enemy)
+T.check(math.abs(cpx - 26) < 1 and math.abs(cpy - 96) < 1,
+  ("the wide lens lands the player's mark on the same anchor: (%.2f, %.2f)")
+  :format(cpx, cpy))
+T.check(math.abs(cex - 124) < 1 and math.abs(cey - 56) < 1,
+  ("and the enemy's too: (%.2f, %.2f)"):format(cex, cey))
+T.check(span(closeRig, shot.player) < span(rig, shot.player),
+  "the mons render smaller on it, which is what it trades for fitting")
+
+-- an arena picks its rig by name, and anything unnamed gets the default
+T.eq(BattleCam.rigFor({ cam = "wide" }), BattleCam.RIGS.wide,
+  "an arena that asks for the wide lens gets it")
+T.eq(BattleCam.rigFor({}), BattleCam.RIGS.tele, "and one that asks for nothing")
+T.eq(BattleCam.rigFor({ cam = "nonsense" }), BattleCam.RIGS.tele,
+  "as does one that asks for a rig that does not exist")
+
+-- ------- the pins survive the window
+--
+-- The scene renders at the WINDOW's resolution, not the GB's, so the rig's
+-- field of view is widened by the ratio the window bears to the letterbox.
+-- What that has to buy is exactness: the letterbox sub-rectangle of the
+-- widened render must be the framing the rig asked for, or the pics come
+-- unpinned from the ground by however much it is out.
+
+for _, win in ipairs({ { 1920, 1080, 7 }, { 640, 576, 4 }, { 1280, 1024, 7 },
+                       { 800, 720, 5 } }) do
+  local pw, ph, s = win[1], win[2], win[3]
+  local lx = math.floor((pw - 160 * s) / 2)
+  local ly = math.floor((ph - 144 * s) / 2)
+  local wide = BattleScene.letterboxFov(rig.fov, ph, s)
+  T.check(wide >= rig.fov - 1e-9,
+    "a window taller than the letterbox needs a wider lens, never a tighter one")
+  local wx, wy = project(rig, shot.player, pw, ph, wide)
+  local gx, gy = (wx - lx) / s, (wy - ly) / s
+  T.check(math.abs(gx - px) < 0.01 and math.abs(gy - py) < 0.01,
+    ("%dx%d at scale %d reproduces the GB framing exactly: (%.3f, %.3f) vs "
+     .. "(%.3f, %.3f)"):format(pw, ph, s, gx, gy, px, py))
+end
+
+-- ------- the drift
+--
+-- With the pics pinned, the drift is not decoration on a backdrop -- it
+-- moves the mons themselves, and the whole reason it reads as depth is that
+-- it moves the near one and the far one by DIFFERENT amounts. A backdrop
+-- that merely slid would move them by the same one.
+BattleCam.update(BattleCam.PAN_PERIOD / 4)
+local rig2 = BattleCam.rig(shot, 0)
+local px2 = project(rig2, shot.player)
+local ex2 = project(rig2, shot.enemy)
+T.check(math.abs(px2 - px) > 0.5, "the drift moves the near mark")
+T.check((px2 - px) * (ex2 - ex) < 0,
+  "and the far one the OTHER WAY -- parallax about a point between them")
+T.check(math.abs(px2 - px) < 8 and math.abs(ex2 - ex) < 8,
+  "neither is flung across the frame: the mons drift, they do not travel")
+
+-- very slow: a quarter of the cycle is several seconds, and what it moves in
+-- one FRAME has to be imperceptible
+BattleCam.reset()
+BattleCam.update(1 / 60)
+local slow = BattleCam.rig(shot, 0)
+local sx = project(slow, shot.player)
+T.check(math.abs(sx - px) < 0.2,
+  "one frame of drift moves a mon by a fifth of a pixel")
+
+-- a placed camera declines the world curve outright: the bend exists to
+-- drop the horizon away from a walking player, and here it would tip the
+-- arena floor out from under the two mons pinned to it
+T.eq(rig.curve, 0, "the battle camera switches the world curve off")
+
+-- ------- the depth of field is measured off the two marks
+--
+-- The slab held sharp is the one the mons are standing in, so the band has
+-- to be derived from where they landed rather than from a constant -- and it
+-- has to hold BOTH, which a band narrower than the gap between them would
+-- not.
+local BattleDOF = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleDOF")
+local focusY, band, range = BattleDOF.bandFor(96, 56, 144)
+T.check(math.abs(focusY - 76 / 144) < 1e-9,
+  "the band centres between the two marks")
+T.check(focusY - band < 56 / 144 and focusY + band > 96 / 144,
+  "and is wide enough that both of them are inside it")
+T.check(range > 0, "with a ramp out of it, so the band edge has no seam")
+local wide = select(2, BattleDOF.bandFor(120, 30, 144))
+T.check(wide > band, "marks further apart hold a deeper slab in focus")
+
 Pipelines.reset()
 run.release()
 
diff --git a/tools/contact_sheets.py b/tools/contact_sheets.py
new file mode 100644
index 0000000..cab9ca4
--- /dev/null
+++ b/tools/contact_sheets.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""Assemble the per-map battle-arena screenshots into contact sheets.
+
+One shot per map is a lot of files to open one at a time, so this lays them
+out eight to a page -- a 2x4 grid, each tile labelled with its map id -- which
+is enough to judge a whole region at a glance and spot the one that is wrong.
+
+    python mods/DramaticShapeVoxelMod/tools/contact_sheets.py \
+        .scratchpad/areas .scratchpad/sheets [order.txt]
+
+Reads every PNG in the source directory and writes sheet_01.png,
+sheet_02.png ... into the destination.
+
+The optional third argument is a file of map ids, one per line, which both
+FILTERS and ORDERS the sheets. That is what makes them worth looking at: the
+authored list in data/battle_arenas.lua is grouped by region, so feeding its
+keys through puts the routes together and the caves together, instead of
+alphabetical order interleaving a gym with a tower floor. Without it every
+PNG in the directory is used, sorted by name.
+"""
+
+import os
+import sys
+
+from PIL import Image, ImageDraw, ImageFont
+
+PER_SHEET = 8
+COLS, ROWS = 2, 4
+TILE_W = 960                    # each shot is 3840 wide; a quarter of that
+LABEL_H = 34
+PAD = 8
+BG = (18, 18, 20)
+LABEL_BG = (32, 32, 36)
+LABEL_FG = (232, 232, 236)
+
+
+def font(size):
+    for name in ("consola.ttf", "DejaVuSansMono.ttf", "arial.ttf"):
+        try:
+            return ImageFont.truetype(name, size)
+        except OSError:
+            continue
+    return ImageFont.load_default()
+
+
+def tile(path, w, h, label, f):
+    """One labelled cell: the shot scaled to fit, with its map id under it."""
+    cell = Image.new("RGB", (w, h + LABEL_H), LABEL_BG)
+    try:
+        shot = Image.open(path).convert("RGB")
+    except OSError as err:
+        d = ImageDraw.Draw(cell)
+        d.text((8, 8), "unreadable: %s" % err, fill=(220, 90, 90), font=f)
+        return cell
+    shot.thumbnail((w, h), Image.LANCZOS)
+    cell.paste(shot, ((w - shot.width) // 2, (h - shot.height) // 2))
+    d = ImageDraw.Draw(cell)
+    d.rectangle([0, h, w, h + LABEL_H], fill=LABEL_BG)
+    d.text((10, h + 7), label, fill=LABEL_FG, font=f)
+    return cell
+
+
+def wanted(src, order_path):
+    """The PNGs to lay out, in the order to lay them out in."""
+    have = {n.lower(): n for n in os.listdir(src)
+            if n.lower().endswith(".png")}
+    if not order_path:
+        return sorted(have.values()), []
+    names, missing = [], []
+    with open(order_path, encoding="utf-8") as fh:
+        for line in fh:
+            key = line.strip()
+            if not key or key.startswith("#"):
+                continue
+            hit = have.get(key.lower() + ".png")
+            if hit:
+                names.append(hit)
+            else:
+                missing.append(key)
+    return names, missing
+
+
+def main(src, dst, order_path=None):
+    names, missing = wanted(src, order_path)
+    if not names:
+        sys.exit("no PNGs to lay out from " + src)
+    # said out loud rather than silently skipped: a sheet set that quietly
+    # dropped a map would read as "every area is covered" when it is not
+    for key in missing:
+        print("MISSING no shot for %s" % key)
+    os.makedirs(dst, exist_ok=True)
+
+    probe = Image.open(os.path.join(src, names[0]))
+    tile_h = round(TILE_W * probe.height / probe.width)
+    f = font(24)
+    sheet_w = COLS * TILE_W + (COLS + 1) * PAD
+    sheet_h = ROWS * (tile_h + LABEL_H) + (ROWS + 1) * PAD
+
+    made = []
+    for page, start in enumerate(range(0, len(names), PER_SHEET), 1):
+        batch = names[start:start + PER_SHEET]
+        sheet = Image.new("RGB", (sheet_w, sheet_h), BG)
+        for i, name in enumerate(batch):
+            cell = tile(os.path.join(src, name), TILE_W, tile_h,
+                        os.path.splitext(name)[0].upper(), f)
+            col, row = i % COLS, i // COLS
+            x = PAD + col * (TILE_W + PAD)
+            y = PAD + row * (tile_h + LABEL_H + PAD)
+            sheet.paste(cell, (x, y))
+        out = os.path.join(dst, "sheet_%02d.png" % page)
+        sheet.save(out, optimize=True)
+        made.append((out, [os.path.splitext(n)[0] for n in batch]))
+        print("%s  %s" % (out, ", ".join(m for _, b in [made[-1]] for m in b)))
+
+    print("\n%d shots -> %d sheets" % (len(names), len(made)))
+
+
+if __name__ == "__main__":
+    if len(sys.argv) not in (3, 4):
+        sys.exit(__doc__)
+    main(sys.argv[1], sys.argv[2],
+         sys.argv[3] if len(sys.argv) == 4 else None)