← Dev Log
BallOrcs6 min read

A canyon, a caravan, and a build that was broken on three platforms

Two new places to be.

In plain words

Two new places to be. Level 3 is a climb rather than a run — ledges stacked up a canyon, with columns of rising wind carrying you between them, and gliding as the shortcut rather than the price of entry. And the barber finally has a shop: a lamplit caravan with a mirror, jars of spare parts on the shelves, and the barber standing behind the chair, waiting.

Nine more parts of your orc can be coloured, and every part now shows a little rendered picture of itself on its button instead of just a name.

Then a check nobody had ever run turned up something alarming. On phones, tablets and the web version, every single component would have failed to load — the whole wardrobe, blank. Desktop was fine, so nothing complained for weeks.

Every component in the pack, rendered through the same preview the character editor uses. This contact sheet is now generated as one image, so a change to the art or the framing shows up as a picture that looks different rather than as nobody noticing.

Under the hood

  • The break: an ExtResource is a hard dependency (6f37535, #51). export_presets.cfg drops */high/* on web, android and ios. All 24 component .tres defs named their mesh as an ExtResource under high/, main.tscn and level1.tscn did the same for the bipedal boots, and no low/ directory existed outside assets/demo. A missing ExtResource does not degrade its owner, it fails it: PROBE RESULT ok=0 null_scene=0 failed=3, zero of 24 components plus both scenes, on three of six shipped platforms. The pipeline was green throughout — the export succeeds, and the failure is at load time on platforms nothing in CI plays. Fixed with an optional appearance_path String in ballorcs-core (a string carries no dependency, so the def loads everywhere) resolved through AssetPath at registration.
  • Both variants now come out of one build. ballorc_component_lib.export_variant writes high and low together, so "regenerated high, forgot low" is impossible rather than merely unlikely; generate_eyes.py and generate_mouth.py fold onto it. Mesh bytes 401,408 → 287,724 (-28%), triangles 10,108 → 5,920 (-41%), exported pack 769,300 → 674,244 (-12.4%). Decimation is COLLAPSE at 0.5, skipping meshes under 40 faces — collapsing an eight-sided cone destroys it rather than simplifying it, and seven parts are already too simple to reduce. New tests/asset_variants (236 checks) pins that nothing under project/ names a variant directory in an ext_resource, and that the low set is lighter without having collapsed (worst bounds drift 0.0066 m on a 0.5 m orc).
  • L3, the Sky Fount canyon (e774988, #35). Three ledges and the Fount platform stacked up, per NARRATIVE.md beat 3. The shape is dictated by one constraint: GoalSeekPolicy.think() returns {move, jump} and nothing else, so the goal bot can never hold ability — which is why #21 shipped no glide-gated required route. Wind volumes push any RigidBody3D regardless of ability, so the required route is wind and a speed pad, and glide is the shortcut, flattening each climb into a longer arc and opening two bonus ledges. Second rule from the same file: a waypoint is only consumed while grounded, so every waypoint here sits on standable ground.
  • The stock updraft lifts nothing. PlayerBall::_ready pins gravity_scale to jump_gravity_scale (2.0), so effective gravity is 19.6 m/s² — and UpdraftFan's shipped lift_force of 18 N on a 1 kg ball is below that. The flight demo's column was decorative. playtest/wind_probe.tscn measured the family and gives the authoring rule rise ≈ column_height * lift_force / 19.6; L3's columns use 40 over 8 m, with margin because the bot steers sideways the whole way up.
  • A bug in the entire wind-volume family. _on_body_entered was type-guarded, _on_body_exited was not, and _inside is Array[RigidBody3D] — TypedArray.has() reports an impossible type as an engine error and returns false rather than raising, so every static body leaving a volume printed two errors. It needed level geometry overlapping a volume, which the flight demo never has and L3 has everywhere. Pinned at the source, deliberately: a behavioural assertion passes on the broken code, and the first one written did exactly that — printing the errors while reporting PASS. Soak: goal mode, three seeds, wins=17, respawns=0, 2132.6 m, and zero engine errors where there had been a wall of them.
  • The barber's caravan (9f696ce, #44) — and it is deliberately not a LEVELS entry. goal_flag.gd::all_levels_complete walks LEVELS to decide whether a win routes to the slice outro; a room with no goal flag can never be complete, so listing the shop there would mean the outro never fires again, silently and with nothing logged. tests/barber_shop pins that three ways, as rules rather than counts. The idle is three periods that do not divide into each other (breathe 3.1 s, sway 4.7 s, glance 7.3 s) so it cannot be seen to loop, and the glance is a raised cosine held near zero for most of its cycle — the barber looks up now and then instead of panning like a security camera.
  • Nine parts become tintable (ee40fd3, #106). The structural mesh takes a shared region (primary/secondary, so the part recolours in sympathy with the orc rather than adding a swatch row of its own) and accents opt out with "" — teeth, claws, nostrils, soles, propeller blades, and everything emissive. Tagging is all-or-nothing per part, because an untagged mesh falls back to the declared region and flattens anyway. accessory.tail_flame is now the only colourless def, so #102's empty-colour hint is reachable by exactly one outfit instead of five slots' worth. Also fixed: every generator hardcoded an absolute output path, and generate_feet.py's still named a scratch worktree — a generator that reported success and changed nothing.
  • Eight verification scripts, and nothing ran any of them (f99ddb2, #47). Around 800 lines written alongside five previous issues, each ending in RESULT: ALL_OK, gating nothing — and two were already red on main, since #78 added a fifth options slider that two of them counted. Neither was a product bug, which is the point: an unrun check does not sit quietly at passing, it rots. Both rewritten to name what they care about. Three more things fell out: the documented invocation (--path project --script tools/...) cannot work at all, since --path resolves the script against res://; verify_pause_boot's two phases need one container, because docker run --rm discards the user:// profile it seeds; and verify_ui_sfx was flaky, asserting player.playing right after a 45 ms blip that one loaded frame can outlast. A play_counts counter replaced observation with counting. The gate earned itself immediately — verify_outro_ui went red because it hardcoded {"level1", "level2"} as the full catalog and #35 had just added level3.
  • The screenshot gate is unstuck (09a9e3f, 3746862, #109/#110). The cached baseline could only refresh from a passing run, and #100's settle work legitimately changed what the anchor shots show — so the gate could never pass, so the baseline could never advance: 37 jobs, one success, silent behind allow_failure. The baseline is now 120 committed PNGs at tests/baseline/, updated by PLAYTEST_UPDATE_BASELINE=1 plus a reviewed commit. And playtest now runs on merge requests, so a render change is observable before merge rather than after — 166 s on top of a build the MR pipeline was doing anyway.
  • Component thumbnails (7e19545, #17) render through the same BallOrcComponentThumbnail the editor uses, not a re-implementation, and tools/thumbnails/render_sheet.gd collects them into the sheet above. It needs a display and says so loudly without one, because a SubViewport renders nothing under --headless and the failure mode is a blank sheet that never fails.
  • GitLab CE 19.1.2 → 19.1.6 (df65b1b), out of band for CVE-2026-19478 (critical GraphQL code injection) and CVE-2026-19650 (CSRF via the multiplex handler). Binding to localhost does not mitigate the second one — CSRF fires from the browser, which is already inside the trust boundary and already signed in.
#levels#art#pipeline#testing#ci