---
name: melonjs-ui-and-text
description: "Use this skill for HUDs, buttons, menus, dialogue panels and on-screen text in melonJS. Covers UIBaseElement/UISpriteElement/UITextButton, Draggable and DropTarget, the floating screen-space container pattern, Text and BitmapText, web font loading, and NineSliceSprite panels. Triggers on: UI, HUD, button, UIBaseElement, UISpriteElement, UITextButton, Draggable, DropTarget, menu, dialogue, Text, BitmapText, font, fontface, wordWrapWidth, NineSliceSprite, score display, floating."
license: MIT
---

# UI, HUD and text

## Outlined text: the stroke eats into the glyph

`Text` draws `fillText` and then `strokeText`, so the outline lands **on top of
the fill** and is centred on the glyph edge — half of it inward. On a small or
chunky face a `lineWidth` of 3 leaves the letters solid black. Keep it to 1,
and raise the font size rather than the stroke.

The stroke used to cost you the top row of pixels as well, and a display face
whose glyphs overshoot the nominal ascent did the same on its own: the render
box was sized from the line height alone, with no allowance for either. **That
is fixed** — the bake is padded by the ink's real extent and the blit shifts
back by the same amount, so nothing is clipped and the reported bounds are
unchanged. Write the string you mean:

```js
new Text(x, y, { font: "Display", lineWidth: 1, lineHeight: 1.45, text: "" });
hud.setText("SCORE 100");
```

If you have a label carrying a leading `\n` to buy headroom, that workaround is
now dead weight — drop it, and take the line height back off the position you
shifted it by.

## Gradient text

`fillStyle` takes a `Gradient` as well as a colour — the same object
`Renderer#setColor` accepts, built the way the canvas API builds one:

```js
const ramp = renderer.createLinearGradient(0, 0, 0, 24);  // top to bottom
ramp.addColorStop(0, "#fffdf0");
ramp.addColorStop(1, "#f0a020");

new Text(x, y, { font: "Display", size: 24, fillStyle: ramp });
```

Coordinates are the label's own bake: `(0, 0)` is the top-left of the render
box, so the ramp above runs down one line. A multi-line label **restarts it on
every line**, reading like one `Text` per line — you do not have to author the
gradient over the block height. Pass `gradientPerLine: false` for a single ramp
spanning the whole block, which is what a plain canvas does and what a
deliberate fade across a two-line title wants.

The ramp colours the **fill only** — `Text` strokes in a separate pass, so
an outline keeps its own colour without any luminance trickery — and it works on
Canvas2D, which a post effect does not. `fillStyle.alpha` still gates the fill,
and the property still reads back as a `Color`.

## BitmapText: `size` is a RATIO, not pixels

The trap when moving over from `Text`:

```js
new Text(x, y,       { font: "Arial", size: 24, text: "SCORE" });  // 24 pixels
new BitmapText(x, y, { font: "arial", size: 24, text: "SCORE" });  // 24 TIMES
```

`size` scales the font's authored size, so `1` is native and `2` is double —
`resize(scale)` and `set(textAlign, scale)` take the same ratio. Whole numbers
keep pixel art crisp; fractional ones resample the page image.

It has **no stroke** — there is no `strokeStyle` or `lineWidth` here, which is
part of why it stays sharp. Colour comes from the tint instead:

```js
const score = new BitmapText(8, 8, {
    font: "arial", text: "1000", fillStyle: "#ffd700",
});
score.fillStyle = "#ff4040";                  // recolour at any time
score.fillStyle = new Color(255, 255, 255);   // UNTINTED, not "white text"
```

`fillStyle` is `Renderable#tint` under another name: white is the *absence* of a
tint and every other colour tints away from it, so author the page in white to
keep every colour available to you.

Load the descriptor as `binary` and its page as `image` under the **same name**.
Both BMFont flavours — text (`.fnt`) and XML — are auto-detected, so an `.xml`
descriptor loads as-is:

```js
loader.preload([
    { name: "arial", type: "binary", src: "data/font/arial.fnt" },
    { name: "arial", type: "image",  src: "data/font/arial.png" },
]);
```

Reach for it over `Text` when the text is mostly static, has to stay crisp at
integer scales, or wants recolouring without a re-bake.

## The HUD pattern

A HUD is a `floating` container at a high z, built once and re-added:

```js
class HUD extends Container {
    constructor(app) {
        super(0, 0, app.viewport.width, app.viewport.height);
        this.floating = true;       // screen space, not world space
        this.isPersistent = true;   // survives a world reset
    }
}

app.world.addChild(new HUD(app), 100);   // explicit z — not `.z = Infinity`
```

There is no `UIContainer` class. Use `Container`, or `UIBaseElement` when the
panel itself must react to the pointer — it is a `Container` that already sets
`floating = true` and `isKinematic = false`.

Three things matter here:

- **`floating = true`** opts the container out of camera transforms, so it stays
  put while the world scrolls. Without it the HUD scrolls away — a silent
  failure.
- **Only the parent container needs `floating`.** `addChild` forces
  `child.floating = false` under a floating parent, and the container applies one
  projection swap for the whole subtree.
- **Pass an explicit z.** `this.z = Number.POSITIVE_INFINITY` appears in several
  shipped examples and does nothing — `renderable.z` is not a real property. The
  real accessor is `depth` (an alias for `pos.z`); `addChild(child, z)` sets it
  for you.

### Buttons: extend the handlers, do not bind listeners

`UISpriteElement` is a `Sprite` that already registers itself for pointer
events, so a button is made by overriding methods rather than by wiring
`registerPointerEvent`:

| handler | fires | returns |
|---|---|---|
| `onClick(event)` | pressed | `false` to stop the event propagating |
| `onRelease(event)` | pressed and released | `false` to stop propagating |
| `onOver(event)` | pointer enters | — |
| `onOut(event)` | pointer leaves | — |
| `onHold()` | pressed and held | — |

```js
class MuteButton extends UISpriteElement {
    constructor(x, y) {
        super(x, y, { image: atlas, region: "speaker.png" });
        this.setOpacity(0.5);
    }
    onOver() { this.setOpacity(1.0); }
    onOut()  { this.setOpacity(0.5); }
    onClick() {
        audio.muteAll();
        return false;          // consumed — do not fall through to the world
    }
}
```

The pointer still has to reach it: a renderable with `isKinematic = true` — the
default on a plain `Renderable` — is skipped by the broadphase and receives
nothing. `UISpriteElement` and `UIBaseElement` clear it for you; anything else
you make clickable has to clear it itself.

### In a 3D scene, a HUD needs a SMALL depth

`floating` opts a renderable out of the camera transform. It does **not** opt it
out of the depth sort, and the two sorts read z differently:

| container `sortOn` | ordered by | on top |
| --- | --- | --- |
| `"z"` (default, 2D) | `pos.z` | **highest** z |
| `"depth"` (what `Camera3d` sets) | distance from the camera | **nearest** the camera |

Under `"depth"` a floating child is ordered by `|pos.z|` alone — its `pos.x/y`
are screen pixels, not a place in the world, and the camera does not move
relative to it. So the *magnitude* is the distance, and the sign is ignored:

```js
world.addChild(hud, -150);        // small -> in front of the whole scene
world.addChild(backdrop, -10000); // large -> behind the whole scene
world.addChild(backdrop, 100000); // equally far: sign does not matter
```

Give a HUD the huge z that would put it on top in 2D and it lands at the far end
of the level instead, with the scenery drawing over it. Both shipped idioms are
the same rule: afterBurner's HUD sits at `-150`, and the glTF, Billboard, Night
City and Instanced Forest examples park a floating sky at `-10000` or `100000`.
