Taking a PC ARPG to iPhone and Android
A game built for keyboard, mouse and a 1080p monitor, moved onto a 65 mm tall screen in one afternoon and one evening: millimetres instead of pixels, a notch the HUD has to dodge, a second finger Godot doesn't see, and an iPhone build made on Windows that still needs a Mac.
Mobile controls would be nice as we want to play it on iPhone and Android. Not sure how the server stuff will work though.
One sentence, two agents #
Dekiro was a PC game for six days. Its windows were drawn for 960 x 540 UI pixels, every tooltip opened on mouse hover, splitting a stack of potions was Shift + drag, and the hotbar answered to the number keys. Then the human asked for phones, with the worry quoted above.
The server worry turned out to be the small part. The game is server-authoritative even in single player:1 Post 3 explains the four network roles and why single player is a local server. a phone runs its own local server exactly as a PC does, and a phone joining a friend is just another ENet client. Two things don't carry over. Steam doesn't exist on phones, so phones join by address, and hosting on a phone (battery, mobile networks, carrier NAT) is labelled "experimental" in the menu with a tooltip that recommends a PC host or a dedicated server. Characters stay on the device; one character across PC and phone would need a cloud save, which is a later step.
The real work was the interface. After a short plan, the human's reply was one line: "Spawn two agents. Android and iPhone." The split that made them able to work in parallel2 Post 11 has the rules for running agents side by side in one repository, most of them learned from file collisions. was by layer, not by platform:
- The Android agent built what both platforms share in the world: the on-screen controls, a mobile renderer and quality profile, and the Android export.
- The iPhone agent built what both share in the windows: screen density, the notch, touch-friendly windows, and the iOS export.
They met in one small static class, Mobile, that the iPhone agent published as an API on its first pass
so the other one could size its buttons with it.
Sizes in millimetres #
The PC UI scales by whole numbers: 960 x 540 UI pixels times 2 on a 1080p monitor, times 4 on 4K. That rule means nothing on a phone. An iPhone 15 Pro in landscape is 2556 x 1179 pixels but only about 65 mm tall, and what matters is how big a button is under a thumb, not how many pixels it has.
So on touch screens the scale comes from the screen's density:
## The UI scale on touch screens: one UI pixel is about UI_PX_MM millimetres (a 10 px body font is then
## ~1.7 mm, iOS's smallest comfortable text), never fewer than TOUCH_MIN_HEIGHT UI pixels high (the
## windows were drawn for 540) and snapped to quarter steps.
const UI_PX_MM := 0.17
const TOUCH_MIN_HEIGHT := 360.0
An iPhone 15 Pro (460 dpi) gets 3x, which makes its screen 852 x 393 UI pixels. A 2400 x 1080 Android phone gets 2.5x, an iPad 1.75x. The player's "Interface size" option still multiplies the result.
Everything that has to be touched is specified in millimetres too, through Mobile.target_px(viewport, mm). The brief asked for buttons of about 10 mm, which is what platform guidelines recommend. The agent
pushed back with arithmetic: a landscape phone is only about 65 mm tall, so 10 mm buttons (or even
Apple's 7 mm) would leave room for about five rows, and a 9-wide bag would no longer fit. Its rule, from the
code:
const TARGET_MM := 6.0
const SLOT_MM := 5.5
const SNAP_MM := 3.0
That is the fat-finger snap: buttons look 6 mm, but a tap that hits nothing pressable goes to the nearest button within 3 mm. Visually the windows keep their density; functionally each button is as wide as a fingertip wherever it has no close neighbour. Item slots are 5.5 mm so the bag stays 9 wide. The close X keeps its drawn size and gets a 6 mm hit area.
The notch #
SafeArea is the second piece. Phones have a notch or Dynamic Island, a camera hole, rounded corners and a
home indicator, and none of the HUD may sit under them. SafeArea attaches to a CanvasLayer and moves
each child's offsets in by the safe-area insets, keeping its anchors. The full-screen HUD layers (player
frame, hotbar, quest box, banners) shrink to the safe rectangle; anchored windows stay inside it; dragged
windows are clamped to it by the window dragger. Things drawn over world positions (health plates, name
tags, the fishing bobber's "!") opt out, because they belong to the 3D world, not the screen.
On a PC, without a phone in reach, the same code runs against a simulated device:
godot --path . -- --touch # touch mode + a simulated iPhone 15 Pro notch and home bar
godot --path . -- --touch --notch=android --dpi=420 # a punch-hole Android phone at its density
--touch turns on every touch rule (the mouse becomes one finger), --notch picks the outline, --dpi
the density. The notch, rounded corners and home bar are drawn over the screenshot, so every image in
this post shows exactly what would be hidden on the real device. Without the flags a PC gets the desktop UI
unchanged; one agent checked that with a pixel diff of the inventory window before and after its changes.
Controls: a stick, a big button, and things that appear #
The on-screen controls follow the shape most mobile ARPGs (that popular Korean MMO's own mobile version included) settled on:
- Left: a floating stick that appears where the thumb lands. Pushing it past 1.3 times its rim sprints.
- Right: a 15 mm attack button (hold it and the combo keeps going), the six skill slots on two rings around it with cooldown sweeps, seconds left, a mana tint and their rank, and the two potion slots beside them.
- Context buttons appear only when there is something to do, and name it: Talk, Pet, Open, Read, Travel, or the dungeon devices' verbs (Pull a lever, Light a candle, Turn a valve). A Take button appears when loot is in reach.
- Camera: one finger on empty screen rotates, two fingers pinch to zoom.
- Menu row: Bag, Skills, Hero, Map, Party, Menu, top right.
Every finger belongs to whatever it first touched, so the stick, the attack button and the camera work at
the same time. The project's first architecture rule paid off here: controllers drive actors only through
command_* methods.3 From the project's rulebook (post 1): PlayerController, the AI and network input must be
interchangeable, so none of them may touch an actor's velocity or combat internals. Touch input is a new source in PlayerController, next to the keyboard and
the gamepad, and nothing on the actor side changed. Touch skills aim like the gamepad's (in the direction
you face), and a touch attack while standing still turns toward the nearest enemy within 4.5 m, because
there is no mouse cursor to aim with.
A test feeds the layout four screen shapes (an iPhone, an Android phone with a camera hole, a small phone and an iPad) and checks that no two controls overlap and none is smaller than its minimum size. It caught one real problem: the top button of the outer skill ring sat on the minimap's clock. The ring now leans left.
A crowded top edge #
The first screenshot of the touch HUD worked and looked busy. The menu was six big round buttons that covered the zone banner and touched the minimap, and the NPC name tags of the town piled up behind them. A cleanup pass the same evening changed four things, all only on touch:
- The menu row became a tight row of 7 mm square tiles with pixel icons, left of the minimap.
- A banner band hangs under the row. The zone name shows there, and so do achievements, "New quest" and "Quest complete", queued behind each other, instead of floating over the fight. A later pass sent the other big popups over your own character there too: "LEVEL N!" (several level-ups in the queue merge into one banner), "School of X", and any popup at least 1.4 times the normal size that isn't a number. Damage numbers (crits are 1.35) and small messages stay in the world.
- Name tags dodge the HUD. Every HUD part reports its rectangles through a
hud_rects()method; a tag that collides with another tag or a HUD part drops the NPC's title first, and if it is still under the HUD it dims to 22 percent. The placement became a pure function, so it has its own test. - The hotbar moves beside an open window, whole, or hides when there is no room. On touch it is only shown while a window is open, as the drop target for skills and potions dragged out of it.
Windows taller than the screen #
Every window was designed for 540 UI pixels of height. An iPhone's safe area at 3x is 372. A new fit shot
in the mobile scenario opens every window one by one and prints its rectangle against the safe area,
"OK" or "CUT". Three windows were cut: the inventory, the skill window and the shop.
FitScroll is the general tool: a container that grows up to a height and then scrolls. Wrapping a whole
window in it works, but it scrolls the buttons away too, and a shop whose Sell button is off screen is
broken. So windows can now mark their middle as the body (TouchUi.mark_body), and on touch only that
part scrolls: the skill list, the NPC window's quests and goods, the storage pages. The title, the gold
line, Sell and Goodbye stay where they are. Windows that mark nothing still scroll as a whole.
The inventory got its own compact layout instead: the equipment moves into two columns beside the Bag, Skillbooks and Runes pages. That makes it 332 UI pixels tall, and it fits without scrolling, with every slot still 5.5 mm.
Touch has no hover and no right button #
TouchUi is the layer that makes Godot's ordinary Controls behave on a touch screen without rewriting each
window. It walks the UI as windows open and applies a set of rules:
- Long-press tooltips. Hover doesn't exist, so a finger held still for 0.45 s shows the tooltip, placed above, below or beside the finger so the finger doesn't cover it. It stays until the next tap, and the release that ended the long press doesn't count as a click.
- Double-tap is right-click. Equip, use and read are right-click on a PC. Every hint and tooltip text
that mentions right-click says "double-tap" on touch (
Mobile.touch_text). - Drag to scroll, with a fling, on every ScrollContainer, list and rich text. A press that moves more than 2.5 mm becomes a scroll and cancels the button under it, except on drag sources: item slots and skills keep drag and drop.
- Splitting a stack was Shift + drag. There is no Shift on a phone, so the inventory has a Split toggle next to the bin. Turn it on, drag a stack onto an empty square, and the amount comes from a slider instead of a number field, so no keyboard opens. The toggle turns itself off after one use.
- Text fields open the right on-screen keyboard (a number pad for the port, a URL keyboard for the join address, a password keyboard for passwords). The screen slides up so the focused field stays above the keyboard, and a tap elsewhere puts the keyboard away.
The second finger #
One problem needed a piece of engine knowledge. Godot turns touches into mouse events so that every Control works on a touch screen, but only for one finger: the first one down, until it lifts. With a thumb on the stick, a tap of another finger on a window's button reached nothing, because buttons only listen to the mouse.
TouchRouter fills that gap. TouchUi feeds it every touch. A finger that isn't the mouse finger and lands
on a window or on anything pressable becomes a tap: when it lifts, still on the same control, the router
pushes a left press and release at that point into the viewport. It stays deliberately small. Drag and
drop, long-press tooltips and scrolling remain with the first finger, and nothing is routed while the first
finger is itself on the UI, because it holds the interface's mouse focus and a second press would steal
it mid-drag.
This is the one feature here that is covered by a unit test only. The PC simulation has one mouse, so one finger. It is on the list for a real phone.
The mobile renderer #
The desktop build uses Godot's Forward+ renderer with volumetric fog, SSAO, glow and dense grass. Phones
get the Mobile renderer (set per platform in project.godot, so the PC keeps Forward+) and a "Mobile"
quality profile that phones start on:
# phones: plants / clutter drawn ~60% as far (the fog hides the edge), half the weather, short 2-split shadows
"mobile": {"view": 0.6, "particles": 0.45, "shadow_distance": 45.0, "shadow_splits": 2, "camera_far": 190.0},
On top of that: grass at a quarter of its density, fading out at 26 to 38 m; a 2048 shadow map with hard filtering; no SSAO or volumetric fog (the Mobile renderer has neither); 75 percent render resolution and 30 fps by default. A new Upscaling option offers FSR, but Godot 4.7 only has FSR in Forward+, so on the Mobile renderer it falls back to bilinear by itself.
Shaders were the risk. Every custom shader in the game (the sprite characters, the skill effects, the
foliage, the dungeon cutaway) was written against Forward+, and headless tests never compile shaders.4 Post 4 tells the story of a uniform name that turned every sprite into a white wall, which no
headless test could see. Since then every shader change gets a windowed screenshot. The agent took windowed screenshots with --rendering-method mobile of the Vale, every room of a
dungeon, every Blade skill and the sprites, and compared them with Forward+. No shader needed a change.
The visible difference is the missing ambient occlusion. One limit to watch on a device: the Mobile renderer
draws at most 8 point and 8 spot lights per object, and the towns have many flickering lanterns.
Android: an APK from a Windows PC #
The Android preset is plain: arm64 only, package io.dekiro.game, landscape following the phone's
rotation, immersive mode, internet and Wi-Fi state permissions, the Steam addon excluded. The same include
filters as every other preset.5 Post 25 tells how the sprite atlases once went missing from every export because a JSON file
isn't a resource.
The first export stopped at missing tools. Building an APK from Godot needs:
- A Java JDK 17. Not the newest JDK: Godot 4.7's Android export wants 17, so that's what went in, as a portable zip in the user's folder, since no installer for it runs without admin rights.
- The Android SDK command-line tools with platform-tools, build-tools 35 and the android-35 platform, licences accepted. The AI asked the developer before accepting those on their behalf.
- A debug keystore for signing, one
keytoolcommand. - ETC2/ASTC textures. Desktop GPUs read BC-compressed textures; phones (and Apple Silicon Macs) need
ETC2 or ASTC. One project setting,
import_etc2_astc=true, makes the importer write both. It had already been switched on a day earlier for the macOS build.
Both tool paths go into Godot's editor settings, and from then on an APK is one command:
godot --headless --path . --export-debug "Android (Game)" build/game-android/Dekiro.apk
The result is a 147 MB debug-signed APK that installs from a file, after allowing "install unknown apps" for whatever app opened it.
iOS: an Xcode project, not an app #
An iPhone build can't be finished on Windows, and the iPhone agent didn't pretend otherwise. Godot can
export an iOS Xcode project from any platform: Dekiro.xcodeproj, an Info.plist with every icon size,
the launch storyboard, the 120 MB game pack, the Godot engine as an xcframework and MoltenVK (Vulkan on
Metal). Building, signing and installing it needs Xcode, which only runs on macOS. Godot says as much:
".ipa can only be built on macOS".
Two smaller lessons from the export:
- Godot refuses to export at all with an empty App Store Team ID, even for a project-only export. The agent tested with a dummy ID and set it back to empty; the real one goes in on the Mac.
- GodotSteam has no iOS library. The preset excludes
addons/godotsteam/*, and the export then leaves out the extension list as well, so nothing tries to load a library that isn't there.
The app icon and the launch screen are drawn by a script from the game's own UI art: a crimson seal with
影 ("shadow") on the window lacquer, and the start menu's title row. docs/MOBILE.md has the Mac steps:
Xcode 16 or newer, an Apple ID (a free one runs on your own phone by cable for 7 days; TestFlight needs the
paid program), automatic signing, Developer Mode on the iPhone, and a local network permission text for
joining friends on the same Wi-Fi.
Testing on a PC #
Everything in this post was built and checked without a phone in the room. Unit tests cover the pure
parts: the size math, the safe-area insets, long-press timing, the stick's dead zone, the HUD layout on four
screen shapes, name tags against the HUD, the second-finger router and the iOS preset's filters.
tests/scenario_mobile_ui.gd opens a phone-shaped window with --touch, takes shots of every window, and
checks behaviour on the way: a drag scrolls a list, a tap beside a button snaps to it, an item dragged by
"finger" lands in its slot. tests/scenario_touch.gd sends real touch events through the engine in town
and in a fight. The monitor can't show a 2556 x 1179 window at full size, so many shots ran at half size
(--size=1278x590 --dpi=230), which gives the same layout.
What a PC can't tell you #
As of this post, no phone has run the game while anyone watched. The open questions are the honest ones:
- Frame rate. A desktop GPU says nothing about a phone's. The mobile profile is an educated guess.
- Feel. Stick radius, camera drag speed (1.6 times the mouse's), and how much the 4.5 m auto-aim helps or annoys.
- The second finger on real hardware.
- Lights in lantern-heavy towns under the Mobile renderer's per-object limit.
Takeaways #
- Size touch UI in millimetres, from the screen's density, not in pixels.
- Small visible targets plus a snap radius fit more on a 65 mm screen than big buttons, and still answer to a fingertip.
- Pin the header and footer, scroll the middle. A window whose action buttons scroll away is broken on a phone.
- Know your engine's touch emulation. Godot's mouse comes from one finger; anything a second finger should do needs its own path.
- Simulate the device, and draw its notch over your screenshots, so a PC can catch most layout bugs.
- Keep input behind one interface. Touch was a new input source for
PlayerController, not a change to the game.
What's next #
Phones and PCs can now play in the same world. The next post is about the bugs that only showed up once a friend actually joined one.