DEV Community

Cover image for Decentraland warns your scene UI will clash with its mobile controls — but won't say where they are
Edy Cu
Edy Cu

Posted on

Decentraland warns your scene UI will clash with its mobile controls — but won't say where they are

I put two buttons at the bottom of a Decentraland scene, the way the platform's own mobile guidance says to. On a real phone, one of them wrote a permanent row to my database every time somebody tried to jump.

Decentraland's mobile documentation does warn you about this. It says scene UI "will clash with the system controls." What it does not do — anywhere I could find — is tell you where those controls are.

What I was building

Mochi is one creature in a meadow, raised by whoever wanders in. Its size is the literal sum of every feeding it has ever had. Its dance is a chain, where each move was taught by one named person, and when it performs it replays the whole chain and credits every move to whoever taught it. Nobody has to be online. You walk in at 2am, alone, and the creature's body is the evidence that people were here.

Source is MIT, on GitHub. It is built with SDK7 and TypeScript against a small authoritative Node server, and it was built for phones from the first commit.

Four verbs: FEED, TEACH, PET, STAMP. PET was always a hold on the creature's own body and STAMP a tap on a totem, so neither needed screen furniture. FEED and TEACH became buttons, because that is what the guidance says to do with actions — put them in the bottom thumb arc, sized large, and don't rely on small targets.

So that is what I wrote:

function ThumbButton(props: { label: string; onClick: () => void; accent?: boolean }) {
  return (
    <UiEntity
      uiTransform={{
        width: '46%',
        height: '100%',
        margin: '0 2% 0 2%',
        justifyContent: 'center',
        alignItems: 'center'
      }}
      uiBackground={{ color: props.accent ? PALETTE.accent : Color4.fromHexString('#FFFFFFe0') }}
      onMouseDown={props.onClick}
      uiText={{ value: props.label, fontSize: TYPE.button, ... }}
    />
  )
}

// ...

{/* Spacer — the creature owns the middle of the screen. */}
<UiEntity uiTransform={{ width: '100%', height: '74%' }} />

{/* Thumb arc. Two verbs, full width, bottom edge. */}
<UiEntity uiTransform={{ width: '100%', height: '14%', flexDirection: 'row', ... }}>
  <ThumbButton label="FEED" accent onClick={() => actions.onFeed()} />
  <ThumbButton label="TEACH" onClick={() => (pickerOpen = true)} />
</UiEntity>
Enter fullscreen mode Exit fullscreen mode

Bottom 14% of the screen, two big targets, plenty of margin. In the desktop preview it looks correct. It looks correct because on desktop there is nothing else down there.

What happened on the phone

The mobile client draws its own controls over your scene: a movement joystick on the left, a jump button on the right, emote buttons either side of it. My 14% strip sat directly on top of all of them.

The joystick was the obvious casualty — you could no longer walk properly, because half your thumb drags were landing on FEED. But the expensive one was quieter. A tap aimed at jump landed on TEACH.

TEACH appends a move to the chain. The chain is append-only, by design and at the schema level: chain_move.teacher_name is NOT NULL, and there is no delete verb anywhere in the server. That is deliberate — an anonymous, editable chain would be a leaderboard, and a leaderboard is not what this is. It also means a misfire is permanent.

By the time I noticed, the live chain carried three consecutive clap moves that nobody chose to teach. A phantom carer had taught my creature to clap, three times, by trying to get over a fence.

Two attempts that didn't work

My first instinct was to move the strip up. It is a percentage; the controls are somewhere below it; find the number that clears them.

I tried twice. Both times it looked clear in the preview and still collided on the device.

Percentages were the wrong tool, and it took me those two attempts to see why. The system controls are laid out by the client, in device pixels, against a safe area that varies by phone. My strip is a percentage of a canvas whose size I do not control. There is no number that is correct on every phone, because the two things are not measured in the same units — and since the platform doesn't publish where the controls sit, there is no number I could derive rather than guess at.

Any value I picked would be a guess that happened to work on the one phone in my hand.

The fix was to delete it

If I cannot know where the controls are, I can still guarantee I am not underneath them — by not being there at all.

I deleted the thumb arc. FEED became a tap on a bowl of berries in front of the creature. TEACH became a tap on a pale stage beside it. They joined PET and STAMP, which had been working as world-space taps the whole time.

The scene now draws nothing below the top 12% of the screen. There is no longer a surface that can collide.

function bowl(): Entity {
  const at = Vector3.create(6.9, 0, 5.8)

  const e = engine.addEntity()
  MeshRenderer.setCylinder(e, 0.42, 0.3)
  MeshCollider.setCylinder(e, 0.42, 0.3, ColliderLayer.CL_POINTER)
  Transform.create(e, {
    position: Vector3.create(at.x, 0.15, at.z),
    scale: Vector3.create(1, 0.3, 1)
  })
  Material.setPbrMaterial(e, { albedoColor: PALETTE.bodyLight, roughness: 0.9 })
  PointerEvents.create(e, {
    pointerEvents: [{
      eventType: PointerEventType.PET_DOWN,
      eventInfo: { button: InputAction.IA_POINTER, hoverText: 'feed Mochi', maxDistance: REACH }
    }]
  })
  return e
}
Enter fullscreen mode Exit fullscreen mode

The commit deletes more than it adds. hud.tsx lost ThumbButton, both buttons, the spacer and the onFeed action, and came out shorter than it went in despite gaining a long comment explaining why the bottom of the screen is now empty.

Two details in that snippet are load-bearing, and both cost me something to learn.

ColliderLayer.CL_POINTER. Give a prop a default collider and it becomes furniture you walk into. The totem is a waist-high post standing exactly where a visitor walks to reach it, so on a joystick that means bumping into the thing you are trying to tap. Pointer-only keeps the hitbox and the hover text and stops it being a wall. The bush and the plaque lost their colliders entirely — nothing taps them, so physics could only ever cost a visitor movement.

The berries stop level with the rim. They are a separate mesh with no collider of its own. Heap them above the rim and they take the tap ray first, then swallow it.

What it cost

Discoverability, honestly. A bowl is less self-evident than a button labelled FEED. I bought it back with a single billboarded word floating over each prop and the client's own hover text, and there is still no tutorial, no onboarding modal and no instruction text anywhere in the scene.

What it bought: the entire class of bug is gone rather than tuned. And the vocabulary that survived is press-and-release only, because the mobile client exposes no drag deltas — screenDelta reports zero there. The hold-to-pet latches on press and completes on a timer, so a thumb sliding off the creature doesn't cancel it. There is no fail state left in the scene.

The scene builds 32 entities against the 200 a parcel allows and 14 materials against 20, measured by a script in the repo that builds the real scene graph and counts it, with a test that fails the build if an addition breaks the budget. Intent latency is p50 0.67 ms / p95 0.94 ms over N=2,160.

What I'd tell you

If your platform documents that a collision is possible but not where it happens, treat every coordinate you pick as a guess — and prefer the fix that makes the guess unnecessary over the one that makes it correct.

And if a misfire in your UI writes something permanent, that is not a UI bug with a data consequence. It is a data bug that happens to be reachable through the UI, and it deserves the more expensive fix.

Honest limitations

  • Catching a move a visitor performs with the client's own emote wheel depends on behaviour the platform docs do not settle. The in-scene picker doesn't depend on it, so what's at risk is spontaneity, not the mechanic.
  • Audio is decorative — the mobile client has no audio event implementation, so nothing in the design depends on sound.
  • The server is a single process with a single SQLite file. Correct for one creature and a few hundred carers; it is not built to shard.

Mochi is live at wunderland.dcl.eth — open it in the Decentraland mobile app. Source: github.com/edycutjong/mochi.

If you go in and feed it, your name stays on it, and the next person who arrives finds you there.

Top comments (0)