Dekiro Dev Blog

Part 19 of 28

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.

— the developer

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.

The touch HUD on a simulated iPhone 15 Pro: a floating stick on the left, the attack button with six skills on two rings and two potions on the right, the menu row as icon tiles left of the minimap, and the notch and home bar drawn over the screenshot

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.

School selection on an Android-shaped screen: before, the "School of Blade" popup floats over the character; after, it is a banner under the menu row. The green Talk button only appears next to an NPC

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.
The same fight before and after the cleanup: the round menu buttons and the zone banner competing for the top edge, then a row of icon tiles with the banner band under it, here showing an achievement

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.

The inventory on an iPhone, before and after: equipment above the bag with the gold row cut off by the home bar, then equipment in two columns beside it, with gold, Split and the bin in view

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.
A long press on a sword in the bag: the tooltip with the comparison to the equipped sword opens beside the finger and ends with "Double-tap to equip"

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.

The Vale's town square at night with the desktop renderer and with the Mobile renderer: the same sprites, shaders and lights, minus ambient occlusion

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:

  1. 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.
  2. 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.
  3. A debug keystore for signing, one keytool command.
  4. 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.