A permanent name, a PAUSE button, and 129 crashes that were one bug
BallOrcs got its permanent phone name, touchscreen prompts that name the real buttons, and a PAUSE button. Plus 129 crashes that were one bug.
In plain words
BallOrcs now has a permanent name — com.helixplaygames.ballorcs — the
one it will carry on a phone forever, because that name can never be
changed once a game is published. Around it, everything a store actually
checks got nailed down: which version of Android it targets, a version
number that climbs by itself with every change, and the signing key that
proves a build came from us.
Playing on a touchscreen got noticeably less silly. The game used to tell phone players to "Roll with W" and press Esc for options — keys they do not have — and there was no way to pause at all. Prompts now name the buttons actually on screen, those buttons have words on them, and there is a PAUSE button in the corner.
We also read every orb and hazard through simulated colour blindness, and they passed.

Under the hood
- 129 crash reports, one non-bug (
edcdba0, #131). The first CI soak that could actually reach the crash backend produced 129 separate issues from a single engine quirk:Area3D's teardown during a scene reload disconnectstree_entered/tree_exitingfrom bodies whose connections are already gone, so every trigger volume a respawning bot overlaps spams "Attempt to disconnect a nonexistent connection". The same spam is why the perf oracle had flagged headlesstick_ms+75% — with crash reporting live, every one of those errors was building and shipping an event mid-tick. Fixed with a guard on all four disconnect pairs inarea_3d.cpp, carried asbuild/patches/godot-area3d-disconnect-guard.patchrather than a forked engine, since the submodule tracks upstream. Two details make that safe:apply_engine_patches()is idempotent and dies if a patch fits neither forward nor reverse, so a moved engine pin can never silently drop the fix; andengine_rev()folds a digest ofbuild/patches/*into the cache stamp, without which cached CI editors would skip the rebuild and stay quietly unpatched. - Android became publishable, in three parts (#129). Package id:
com.helixplaygames.ballorcs, backed by the domain registered this week — permanent once published, so the preset carries a warning comment and the build asserts it (dc753aa). versionCode:git rev-list --count HEAD, which needs no counter file and correctly collides when the same commit is built twice, since the same build should never be uploaded twice. It is computed on the host side of the container re-entry, because inside the container the checkout is dubious-ownership and git refuses — failing there would have silently shippedversionCode=1(8db3342). Signing: release keys arrive as protected CI variables, and the export refuses to finish unlessapksigner --print-certsshows the APK is not debug-signed and is signed by the HelixPlayGames key (53442b2). Separately, target SDK 36 is now pinned rather than inherited, withaapt2badging asserting it after export (d0e5a8b) — the investigation found current builds already shipped 36 by engine default, so this buys the guarantee, not the number. - The Android build broke on a check that had only ever passed by luck
(
c9c3ad1). The 4.7.2 engine bump went green on linux, windows, web, smoke and verify, and failed only onbuild:androidwith "A valid Java SDK path is required". Godot names its editor-settings file by major.minor (editor_settings-4.7.tres); the script wrote${GODOT_VERSION%%-*}.tres, which strips only the-stablesuffix. At4.7-stablethat yielded4.7by coincidence and had always matched; at4.7.2-stableit wroteeditor_settings-4.7.2.tresand the editor went on reading the default, SDK-less file. Now derived explicitly withcut -d. -f1,2. - Touch players were being read instructions for hardware they do not
have (
1717923, #124).prompt_text()gains a touch tier — pad glyph if a pad is connected (a pad on a phone outranks touch), else the overlay's control names, else the keyboard binding. Those names deliberately avoid colour words: "the blue button" would undo the colour-safety stance below. The two colour-only circles are now captioned JUMP and ABILITY, so the prompts have something visible to point at. That change closed with a stated gap — touch had no route to Options at all — which becamefeffd08(#125): a safe-area-aware PAUSE button bound to the samepauseaction every other device uses. The subtle half is surviving the pause it causes:TouchControlsbecomesPROCESS_MODE_ALWAYS, because inheritedPAUSABLEwould freeze the overlay the instant its own button fired, release events would stop arriving, and a joystick held at pause time would latch its move actions. - A parse error had killed every dialog in the game (
fe16974). The pad-B skip usedvar pad_skip :=on an expression with no inferable type — a parse error, sodialog_box.gdfailed to compile andstory_beatswent down with it. Eight rounds of hands-on feel testing missed it because this machine's save has every story beat already seen, so no dialog ever tried to open; CI, on a fresh profile, waited four hours for a prologue that could not exist. The fix is one explicit type, plus the lesson:verify_dialoggrew a 120 s watchdog, because a stage that cannot complete should be a red result, never a job timeout. - Colour safety, judged by a human and re-runnable by a script
(
101b308, #108). The audience skews 8–16 and roughly 1 in 12 boys has some red–green deficiency, which puts a fire level built on reds and oranges closer to correctness than to polish.tools/cvd_sheets.pyrenders each camera anchor as original | protanopia | deuteranopia | tritanopia | greyscale, using Machado severity-1.0 matrices applied in linear RGB — applying them gamma-encoded understates the loss. Verdict: pass, every orb and hazard type differing in silhouette and not only hue, with audio already double-coding the important interactions and rumble adding a third channel. - The camera stopped jittering on displays we do not own (
8221f3b, #70). Physics interpolation was off — the engine default, never set by the project — so the 60 Hz-ticked ball rendered at raw tick positions while the camera moved per frame. Invisible on the 60 Hz reference laptop, guaranteed micro-jitter on anything faster. Turning it on has two consequences, both handled: teleports must snap, not smear (respawn happens inside a physics tick, and an un-reset one measured a 312 m smear across the map for a single frame), and the camera now tracksget_global_transform_interpolated(), so it aims at the ball players actually see. The follow lerp became1 - exp(-speed * delta), giving identical response at every frame rate, where the old linear factor smoothed ~8% harder at 30 fps than at 60. - The perf oracle was measuring two populations, neither of which was
playing the game (
35ac72e, #117). Bots run headless (nothing drawn); the camera probe takes no input (the ball never moves). So the three particle emitters added the week before measured zero draw calls — they sat atemitting = falsefor the probe's entire run, and "idle emitters cost nothing" reads exactly like "these effects are free". A third population,rendered_play, now runs one windowed goal bot for 30 simulated seconds. It is deliberately report-only: no baseline exists for it and no run-to-run noise has been measured, and last week's lesson was that a gate calibrated on nothing fires on everything. - The 60 fps floor, measured rather than asserted (
e2ff841, #79). Windowed goal-bot runs across L1–L3 on both renderers, on the reference laptop. Forward+ holdsmin=60on all three levels once the pipeline cache is warm. Thegl_compatibilityfallback holds everywhere except one deterministic dropped frame per lap on L3 — same route position in independent runs, coinciding with the run's highest draw-call window at the spawn vista, where GL on an Iris Xe grazes 16.7 ms. Recorded as a known boundary rather than "fixed", because the fix would be vista work the shipped renderer does not need. - The web build finally met a browser (
fa80336, #86). It had been built every week and nothing ever ran it. A weekly job now servesdist/webwith the headers the loader expects, rewrites the page's own config as served so the in-page harness boots a goal bot, and watches the console over the Chrome DevTools Protocol. Three-way verdict on purpose — pass / fail / harness-missing — so a stale pack is reported as a boot failure instead of hanging the job. The bot wins three times inside headless chromium. - Two more things that could not disagree quietly. The build now dies
when a submodule pointer disagrees with
versions.lock(273a999): the lock is a record, the gitlink is what actually gets compiled, and nothing compared them — one merge had the lock at 4.7.2 and the gitlink still at 4.7, compiled a 4.7 editor, installed 4.7.2 templates, and only noticed 18 minutes in at the export step. And every shipped component definition must now resolve to a live mesh after registration (eaa1e91, #119), so "present in data, absent in geometry" cannot ship quietly whichever half breaks. - Where the game is going, on paper (
99f3ab5, #122). A 20-level plan — five zones of four, one ability unlock per zone, the Hollow King as a colour-drain villain and restoration as the win condition — with the existing three levels becoming hub training grounds. Explicitly a draft for markup, not implementation, and the owner has since marked it up: the hero is the BallOrc, and the goal is to unite the tribes. - Housekeeping. The engine pin moved up to 4.7.2-stable
(
b617a6e); engine builds are serialised with aresource_groupso a second runner slot can pick up cheap jobs without two multi-hour compiles racing into an OOM kill (13b05a2); display suites run under--fixed-fps 60, after a 4-thread build host emitted 141 of the 200 pixels a threshold calibrated on an 8-thread box demanded (4205e18); andsmoke:windowsskips on gitlab.com, where no Windows runner exists (0fa077b).