the symptom, in your words

"ScreenPanel.ZIndex defaults to 100, and tied roots have no paint order"

✓ verified on 26.07.22
lane Building UIposted

▸ SYMPTOM

A full-screen surface goes invisible even though every painter-side health check reads green: the component is valid, the panel built, its children are present, the tree is correct. Nothing in the panel itself is wrong — it painted a perfectly healthy tree — yet on screen there is nothing, or another full-screen root is showing instead.

The tell is timing. It often works for months, then a menu (or HUD, or overlay) that opened fine a hundred times comes up blank after a particular sequence — commonly after some other panel toggled on and off — or right after an editor hotload. On a fresh boot it comes back; mutate the set of live panels again and it can flip back to blank.

▸ CAUSE

Two engine facts combine.

1. ScreenPanel.ZIndex defaults to 100, not 0. From engine source (Scene/Components/UI/ScreenPanel.cs):

snippet
[Property] public int ZIndex { get; set; } = 100;

So every ScreenPanel that never explicitly sets a tier sits at 100 — which is exactly the value most projects reach for when they author a "modal above the HUD" tier. A menu authored "at 100, above the base UI" is not above anything: it is tied with every base root that was left at its default.

2. Two roots with equal ZIndex have no defined paint order. Cross-root compositing is (CameraComponent.OnCameraRenderUI):

snippet
Scene.GetAll<ScreenPanel>().OrderBy( x => x.ZIndex )

OrderBy is a stable sort, so equal tiers keep their input order — but that input order is the scene object index's enumeration order, which is a raw HashSet bucket order with no contract. It happens to match creation order on a fresh boot (which is why the bug hides for so long), and it re-rolls whenever the set mutates:

  • any ScreenPanel disable/enable interleave reuses freed HashSet slots, reshuffling ties;
  • an editor hotload rebuilds the whole index from an unordered HashSet<object> (Scene.HotloadObjectIndex), re-rolling all ties at once.

When two full-screen roots are tied, whichever the compositor happens to emit last paints over the other. The victim painted correctly; it is simply composited under a sibling at the same tier. Every health check you can run lives on the painter side and reads green — the answer is on the compositor side.

▸ FIX

  • Author an explicit tier for every root. Never leave a ScreenPanel at its default. Set the intended "bottom" surface to a real 0 rather than assuming unset means bottom — unset means 100.
  • Keep any two surfaces that can be up at the same time on distinct tiers. Ties are the bug; never let two full-screen roots that can coexist share a ZIndex.
  • When debugging "painted but invisible", dump the roots in OrderBy(ZIndex) order and look at what composites after the victim. Trust the compositor's ordering, not the painter's health checks — the panel being valid tells you nothing about which sibling wins a tie.

▸ WHY IT WORKS

The invisibility is never a fault in the hidden panel — it is a fault in the ordering of equal-tier roots, and that ordering is undefined by construction (OrderBy is stable but its input is an unordered set). Giving every root a distinct, explicit ZIndex removes the tie entirely, so the stable sort has a total order to honor and the result no longer depends on HashSet bucket layout, enable/disable history, or hotload timing. Setting the base surface to a literal 0 (instead of relying on "unset = bottom") closes the specific trap that the default of 100 creates: a modal authored at 100 finally sits genuinely above a base at 0, rather than tied with it.

Verified on engine 26.07.22: seen in a real project.
s&box moves fast; an undated fix is a liability. Spot a stale detail?
changelog
  • Published

Want to know when new guides or fixes drop? Join the community to help build this out. Report gotchas, flag outdated fixes, or just lurk.

Join the Discord