Skip to content

Dev Log: 2026-07-18

The board never actually turns. The camera does.


Why portrait was not just "make a second layout"

Up to now the game was locked to landscape everywhere: black bars on a phone held upright, full stop. That was a deliberate call while the UI was still moving, not a permanent one. This build removes the lock. Rotate a phone or tablet at any point, in a cutscene, on the world map, mid-stage, and the whole UI reflows into a taller, narrower layout instead of staying pinned to landscape or stretching.

The tempting approach is a second copy of every screen sized for a tall canvas. I did not want to maintain two versions of the HUD, the world map, and every dialog forever, so most of this build is about making the existing landscape layouts survive a much narrower canvas, plus one genuinely different problem: the battle board itself.


Keeping UI the same physical size across a rotation

Godot scales its whole canvas by one uniform factor: min(window.width / 1280, window.height / 720). On a landscape phone that is height-bound and lands around 1.5x. Rotate the same phone and the window dimensions swap, so the same formula becomes width-bound and lands around 0.84x. Nothing about the UI moved yet and it already shrank, because the factor is derived from the short edge and portrait's short edge is the narrow one.

ScreenLayout, a new autoload, is the single source of truth for orientation and cancels that shrink:

# Must match display/window/size/viewport_{width,height} in project.godot.
const BASE_W := 1280.0
const BASE_H := 720.0
const PORTRAIT_CONTENT_SCALE := BASE_W / BASE_H

func _apply_content_scale() -> void:
    get_window().content_scale_factor = PORTRAIT_CONTENT_SCALE if _portrait else 1.0

Setting content_scale_factor in portrait cancels the automatic shrink exactly, so the visible rect's short edge is 720 in both orientations. A font-20 label is physically the same size either way; layouts just get a taller, narrower rectangle to reflow into. That one line is the reason no UI code anywhere needed per-element portrait font scaling, and why none should be added back if a future layout looks slightly off.


Rotating the board instead of rebuilding it

The stage grid was the one screen a reflow could not fix, because its content is a fixed-aspect map, not a stack of labels. Rebuilding it for a 9:20 canvas would mean two coordinate systems for pathing, targeting, and every drone AI check that reads grid positions.

Instead the board does not change at all. The camera does:

# In portrait the whole board is rendered rotated so the internal left-to-right
# axis reads top-to-bottom on a tall phone screen. Only the *view* rotates:
# grid cells, coordinates and all combat/movement math stay in the unrotated
# left-to-right frame.
const PORTRAIT_ROTATION := -PI / 2.0

static func board_rotation(portrait_layout: bool) -> float:
    return PORTRAIT_ROTATION if portrait_layout else 0.0

Every gameplay system, pathing, targeting, drone reactions, still thinks in landscape coordinates. Only the camera turns, so player-facing "west to east" becomes "top to bottom" on a tall screen without touching a single line of combat logic.

The catch is that drones carry chrome that has to stay upright regardless: HP bars, the AA/AG badge, the intercept gauge, damage numbers. Rotate the drone's sprite along with the board (correct, it should turn with the map) but leave its children unrotated and their positions still rotate with the parent, so an HP bar meant to sit above the drone swings out to its side instead. The fix is a dedicated child node that counter-rotates as a whole:

# Cancel the camera's board rotation for the chrome under _screen_align, so it
# renders exactly as it does in landscape while the board itself turns.
func _sync_screen_align() -> void:
    if _screen_align == null:
        return
    _screen_align.rotation = StageCameraInput.board_rotation(ScreenLayout.is_portrait())

Everything that must read right-side-up, bars, badges, numbers, gets parented under _screen_align instead of the drone directly. Rotating the parent cancels both the angle and the position offset in one move; rotating each child individually would only have fixed the angle and left the bar hovering beside the drone instead of above it.


Every dialog fits, because the panel clamps itself

Every modal in the game, unit_detail, level-up, stage result, save transfer, was authored against a 1280-wide landscape canvas. Portrait's canvas is only about 720 wide. Centering a 1220px-wide panel on a 720px canvas gives a negative x offset, and the panel hangs off both edges: the level-up dialog's OK button and the stage-result close button were getting clipped away entirely before this fix.

Modal.panel_rect() now clamps every panel's requested size to the live canvas minus a fixed pixel gutter, not a percentage one, because the canvas short edge is 720 in both orientations thanks to the content-scale trick above, so a pixel gutter reads as the same physical size after a rotation:

static func panel_rect(want: Vector2) -> Rect2:
    return centred_rect(want, viewport_size())

static func centred_rect(want: Vector2, vp: Vector2) -> Rect2:
    var size := Vector2(minf(want.x, vp.x - PANEL_GUTTER * 2.0),
            minf(want.y, vp.y - PANEL_GUTTER * 2.0)).floor()
    return Rect2(((vp - size) * 0.5).floor(), size)

That clamp only holds if every label inside the panel is willing to shrink with it, and Godot's Control.set_size() cannot shrink a container below its children's combined minimum size. The unit detail dialog's stats line (ATK/DEF/RANGE and traits, all on one row) was missing autowrap_mode, so its unwrapped natural width became the row's minimum size and silently propped the whole panel back open past the clamp. One label without word-wrap was enough to undo the entire clamping system for that dialog. Turning autowrap on, and taking the opportunity to drop the HP/XP/limit-break bars from the panel entirely since the stage HUD already shows them, fixed it and made the dialog noticeably less cluttered as a side effect.


The rest: reflow, not rebuild

Once the content-scale fix and the panel clamp existed, most of the remaining work was applying the same discipline screen by screen: the world map, cutscene dialogue boxes, the tutorial coach overlay, the objective banner, the skill picker, and the in-stage queue bar all needed their landscape-only pixel math turned into portrait-aware versions.

The queue bar is a representative example. In landscape it pins to the bottom-right corner and always has, because the route-control trio (previous / plan / next) sits centered around x 450-830 while the queue bar sits at 916-1272; the two never overlap. Portrait's 720-wide canvas has no such room, the trio spans 170-550 and the bar 356-712, a 194px overlap that ran the browse row straight through the QUEUE button. The fix keys the bar's position off orientation rather than trying to find one set of coordinates that works for both:

static func queue_bar_y(vp_h: float, portrait: bool) -> int:
    if not portrait:
        return int(vp_h) - QUEUE_BAR_H - QUEUE_BAR_EDGE_GAP
    return route_controls_bottom_y(vp_h) - QUEUE_BAR_ROUTE_GAP - QUEUE_BAR_H

Portrait stacks the bar above the route trio's bottom slot instead of sharing a row with it. Deliberately the bottom slot and not whichever slot is currently live, keying off the live slot would make the bar hop up and down every time the player re-plans a route, which is worse than a fixed position that is occasionally not the closest one.


Testing rotation without a phone

None of this is easy to verify by eye on a desktop monitor that is always landscape. android_sim.gd is a desktop-only dev tool, active only behind a --android command-line flag, that sizes the game window to a 9:20 phone aspect and rebinds Cmd+R (Ctrl+R off macOS) to flip it between portrait and landscape live:

## Sizes the game window to a phone aspect (9:20 portrait by default) and
## rotates portrait<->landscape on Cmd+R, simulating a device rotation so the
## portrait layout paths can be exercised without a real phone.

The rotation swaps the same rectangle's two edges rather than re-fitting a new one, which matters more than it sounds: a real phone keeps the same physical screen when you turn it, and an earlier version of this tool re-fit the window on each rotation, making the simulated landscape window several times wider than the portrait one and making portrait UI look artificially small during testing. That bug would have made every portrait layout look more cramped than it actually is on a device, so a lot of the rest of this build depended on the simulator being right before the layouts themselves could be trusted.

Backing that up, this build added dedicated test coverage for the geometry itself, not just "does it draw": counter-rotation of drone chrome, modal width clamping across canvas sizes, queue bar and route control positions in both orientations, dialogue backdrop reflow, and the objective banner. The kind of bug this catches is exactly the stats-label autowrap issue above, a one-line omission with no visible symptom in landscape at all.


Bug fixes

  • The unit detail dialog overflowed off-screen in portrait. Root-caused to a missing autowrap_mode on the stats label silently defeating the panel's width clamp; see above.
  • The skill picker's layout broke in portrait. Its column math assumed the 1280-wide landscape canvas.
  • The objective banner rendered incorrectly in portrait.
  • Cutscenes and menus did not adapt to portrait orientation. Dialogue backdrops, the tutorial coach overlay, and several menu screens needed the same panel-clamping and reflow treatment as the stage UI.

Miscellaneous

  • GDScript test suite: 1158/1161 passing (3 pending: sprite assets unavailable in the headless test environment, not failures).
  • Python solver test suite: 536/536 passing (17 skipped).
  • Build 89 shipped to Steam, Google Play, and itch.io.

The board-rotation trick is the part of this build I'd point to if someone asked what "add portrait support" actually means under the hood. It would have been easy to reach for a second grid renderer that thinks in portrait coordinates, and then spend the rest of the project keeping two coordinate systems in sync every time combat math changes. Rotating the camera and leaving the game logic alone means portrait support cost almost nothing in the systems that actually matter, pathing, targeting, drone reactions, and all the risk landed in one well-contained place: whatever chrome draws on top of the board and has to remember not to turn with it.