melonjs-renderables · git:20261024.d7db994 · 2026-10-24 · sha256 dd7ef06b26addd99
melonjs-renderables git:20261024.d7db994A
Immutable. This exact content never changes and is served at /api/v1/blob/dd7ef06b26addd99.
---
name: melonjs-renderables
description: "Use this skill when subclassing Renderable, writing a custom draw() method, handling pointer or click events on game objects, applying post effects and shaders, or controlling draw order. Covers the pos-relative draw contract, isKinematic and input, addPostEffect vs the deprecated shader property, and z-ordering. Triggers on: Tween, easing, animate, Renderable, extends Renderable, draw(, update(, isKinematic, pointerEvent, registerPointerEvent, onClick, addPostEffect, removePostEffect, ShaderEffect, addChild, z-order, anchorPoint, floating."
license: MIT
---
# Renderables, input and effects
> Subclassing `Renderable`, custom drawing, receiving pointer events, and
> applying post effects — with the traps that produce silently wrong output
> rather than an error.
Everything here is a **silent failure**: the code runs, nothing throws, and the
result is wrong. Those are the cases worth memorising, because nothing in the
type signatures warns you.
## 1. A custom `draw()` must position itself from `this.pos`
The renderer is **not** pre-translated to the renderable's position when `draw()`
is called. `Container.draw` translates by the *container's* own `pos` and then
calls `child.preDraw` / `draw` / `postDraw`; `preDraw` never translates by the
child's `pos`. Read `this.pos` and draw relative to it.
```js
class Marker extends Renderable {
draw(renderer) {
// ✗ draws at the parent container's origin, not at the marker
renderer.fillRect(0, 0, this.width, this.height);
// ✓
renderer.fillRect(this.pos.x, this.pos.y, this.width, this.height);
}
}
```
The symptom is an object that renders correctly at position (0, 0) and appears
stuck at the top-left as soon as you move it.
**Zero the anchor in custom-draw renderables.** `preDraw` does not translate to
`pos`, but it *does* apply the anchor offset `(-width * anchorPoint.x,
-height * anchorPoint.y)`. With the default centred anchor, drawing at
`this.pos` therefore lands half a size off:
```js
constructor(x, y, w, h) {
super(x, y, w, h);
this.anchorPoint.set(0, 0); // ← or every custom draw is offset by -w/2, -h/2
}
```
## 2. `isKinematic` must be `false` to receive input or collisions
`Renderable.isKinematic` **defaults to `true`**, which means *"physics collision
and input events will not impact this renderable"*. A kinematic renderable is
skipped by the pointer-event dispatcher entirely.
```js
class Button extends Renderable {
constructor(x, y) {
super(x, y, 100, 40);
this.isKinematic = false; // ← required, or clicks never arrive
input.registerPointerEvent("pointerdown", this, () => { /* … */ });
}
}
```
The symptom is a handler that is registered, looks correct, and never fires.
Note that `Container`, `Camera2d`, `Draggable`, `DropTarget` and the `UI*`
elements set it to `false` for you, and so does the `Body` constructor on its
owner (which covers `Entity` and anything you give a body to) — so a plain
`Renderable` or `Sprite` subclass with no body is the case that catches people.
The same flag has a second, less obvious effect: it is also the opt-in to the
world **broadphase**. The quadtree/octree insert skips any child with
`isKinematic === true`, so body-less objects you want `adapter.queryAABB` /
`querySphere` to find need `isKinematic = false` as well. The builtin
narrowphase likewise requires *both* partners to be non-kinematic.
## 3. Draw order: only `addChild(child, z)` works
There are three separate traps here and they compound.
```js
world.addChild(sprite, 10); // ✓ the only reliable form
```
1. **`renderable.z` does not exist.** There is no `z` accessor anywhere on
`Renderable` (nor on `Rect`/`Polygon` above it) — only `depth`, which proxies
`pos.z`, and the container's default `sortOn: "z"` comparator reads `pos.z`.
Writing `sprite.z = 100` creates an inert property that nothing reads.
Several shipped examples still contain `this.z = Number.POSITIVE_INFINITY`;
those HUDs draw on top only because they happen to be added last. (A `z` key
in a `Sprite`/`ImageLayer` *settings* object is a different thing and is real
— it assigns `pos.z` in the constructor — but trap 2 then overwrites it.)
2. **Setting `depth` *before* `addChild` is overwritten.** With `autoDepth` on
(the default), `addChild` without an explicit `z` assigns
`child.pos.z = children.length` after the push, discarding whatever you set.
3. **Setting `depth` *after* `addChild` does not reorder.** A `"z"` sort runs
when a child is added (`autoSort`, deferred to the next tick), not when
`pos.z` changes. If you must change it later, call `container.sort()`
yourself, or use `moveToTop` / `moveUp` / `moveDown` / `moveToBottom`, which
reorder the children themselves. (An `"x"` / `"y"` sort is different: see
below.)
### Sorting on `"y"` (or `"x"`), and `sortOffset`
A container with `sortOn = "y"` draws its children from the smallest `pos.y` to
the largest, so whatever is lower on screen covers what is above it (top-down and
isometric games). `"x"` does the same along x. A different `pos.z` still decides
first, so keep the children that should interleave at one z.
- **Moving a child re-sorts by itself.** With `autoSort` on (the default), the
container checks its order at the end of every update, after physics and the
children's own updates, and re-sorts when anything is out of place: a walking
character needs no `sort()` call. A frame where nothing moved costs one pass
over the children and does not cause a redraw. Turn `autoSort` off to keep
the children in the order you put them.
- **Ties keep insertion order, and the child added LAST is drawn BEHIND.** A
torch placed at its wall's exact position disappears behind the wall.
`sortOffset` changes the ORDER only, never where anything is: the child is lined
up as if it stood that many pixels lower (positive) or higher (negative), and is
drawn exactly where it was. Use it when `pos` is not the point to sort by:
```js
torch.sortOffset = 1; // win the tie with the wall it hangs on
dust.sortOffset = door.pos.y - dust.pos.y; // an emitter's pos is the top of its
// spawn area: line it up with the door
prop.sortOffset = prop.height; // sort by the bottom of the image
```
A container that keeps itself sorted picks a change up at its next update (with
`autoSort` off, call its `sort()`). `"z"` and `"depth"` sorting ignore it. It must be a finite number (a string
or `NaN` throws, rather than scrambling every sibling), and a pooled renderable
starts again at `0`.
The exception: under `Camera3d` the world's `sortOn` is `"depth"`, and the
camera calls `container.sortNow(true)` every frame. Note that mode does **not**
sort by `pos.z` as a layer index — it sorts by squared distance from the camera
in world space (nearest first in the array, painted far→near). So changing
`depth` does reorder there, but as a position change, not as a layer number.
```js
// ✗ overwritten by autoDepth
sprite.depth = 10;
world.addChild(sprite);
// ✗ no resort happens (except under Camera3d)
world.addChild(sprite);
sprite.depth = 10;
// ✓
world.addChild(sprite, 10);
```
## 4. `removePostEffect()` destroys the effect
To disable an effect temporarily, toggle it. Removing it frees its GPU
resources and leaves you holding a dead object.
```js
// ✗ effect is destroyed; re-adding it will not work
sprite.removePostEffect(effect);
// ✓
effect.enabled = false;
```
Exception worth knowing: an effect with `effect.shared === true` opts out of
auto-destroy, because it is reused across renderables.
Use `addPostEffect()` / `getPostEffect()` / `removePostEffect()`. Assigning
`renderable.shader = …` is deprecated since 19.2.0.
Post effects need a programmable pipeline. A `ShaderEffect` constructed against
the Canvas renderer (`renderer.shaderLanguage === null`) logs a warning, stays
at `enabled === false` and no-ops every method rather than throwing — and
`beginPostEffect` filters disabled effects out. Since `video.AUTO` picks the
backend at runtime, an effect that works on your machine may do nothing on
another.
## 5. Opacity cascades; `setChildsProperty` assigns
`alpha` composes down the tree. Fading a container fades everything inside
it, and the values multiply — a child at `0.5` inside a parent at `0.5` draws
at `0.25`. Each renderable keeps its own value; the composition happens at
draw time and is undone when the renderable is finished.
```js
model.alpha = 0.3; // fades the whole rig
container.setChildsProperty("alpha", 0.3, true); // REWRITES each child's alpha
```
The two are not interchangeable. `setChildsProperty` is a bulk assignment: it
changes what the children *are*, so their own `alpha` now reads `0.3` and
stays that way after the container goes back to full. Reach for it when you
mean to change the children, and for `alpha` when you mean to fade a subtree.
Do not assign alpha down a hierarchy by hand. The renderer composes it as it
walks the tree, so setting it at more than one level multiplies: in a rig
nested three deep, `0.3` applied at every level lands parts on `0.3`, `0.09`
and `0.027` depending where they sit. The symptom does not look like an
opacity problem — a model whose parts hold *different* opacities, and which
appears to change opacity as it turns, because turning changes which parts
face the camera.
A useful consequence: a `GLTFModel` is a `Container`, so `model.alpha` fades
every part of a loaded rig — which is what you want for a ghost, a fade-in,
or a reflection.
## 6. `Vector3d.set(x, y)` silently zeroes `z`
```js
v.set(10, 20); // z becomes 0, not "unchanged"
v.set(10, 20, v.z); // keep it
```
`pos` is an `ObservableVector3d`, whose `set(x = 0, y = 0, z = 0)` does the same
— so `this.pos.set(x, y)` wipes the object's depth.
`Renderable#setPosition(x, y, z?)` is the renderable-level form and does not:
omit `z` and the depth is left exactly as it was. Reach for it whenever you
are moving an object rather than editing a vector, and keep `v.set(x, y, v.z)`
for the vector itself.
## 7. A settings key the class never reads is silently ignored
A settings object is a plain literal: nothing rejects a key, so a name that
belongs somewhere else does nothing at all and says nothing about it.
```js
// WRONG — only the RENDERER reads a `blendMode` setting, never a renderable
const glow = new Sprite3d(x, y, { image: "sun", blendMode: "additive" });
// right: it is a property
glow.blendMode = "additive";
```
The same shape catches `transparent` on a `GLTFModel` (its parts carry it —
`model.setChildsProperty("transparent", true, true)`) and any `Mesh` setting
handed to a class that forwards only a curated subset. If a visual option
appears to do nothing, check whether the class actually reads it before
tuning the value.
## 8. `isRenderable` does not hide anything
It gates `updateBounds`, not drawing. Setting it `false` leaves the object on
screen and merely stops its bounds tracking, which is worse than doing nothing.
```js
sprite.isRenderable = false; // WRONG — still drawn, bounds now stale
sprite.alpha = 0; // hides it
```
Use `alpha` for a blink, or remove the child for a long absence.
## A mask belongs to you, not to the renderable
`renderable.mask` takes a shape you created, and `destroy()` releases the
reference without recycling it: the same instance may still be masking
something else, so the engine has no claim on it. If you pool masks, release
them yourself.
Before 20.7.0 `destroy()` tried to recycle it and **threw** for every type
`mask` accepts, because it used the legacy name-keyed pool and no geometry
class is registered there. It failed mid-teardown, so a masked renderable
could not be destroyed or removed without keepalive at all.
## Update and draw
```js
class Enemy extends Renderable {
update(dt) {
this.pos.x += this.speed * (dt / 1000);
return true; // ← true means "redraw me"
}
draw(renderer) { /* … */ }
}
```
`update()` returns a boolean meaning *"this object changed and needs
redrawing"*. The parent accumulates it into the container's `isDirty`, and the
application skips the whole draw pass while nothing is dirty — so returning
nothing (falsy) can leave the scene frozen even though state is updating. The
base implementation returns `this.isDirty`, so `return super.update(dt)` is the
other correct ending.
`update()` is only called when `obj.inViewport === true` or
`obj.alwaysUpdate === true`. An off-screen object with the default
`alwaysUpdate = false` does not tick at all.
## Anchors and floating
- `anchorPoint` defaults to centre `(0.5, 0.5)` on `Renderable`, but `Container`
forces `(0, 0)`. Mixing them up shifts children by half their size.
- `floating = true` pins a renderable to the screen rather than the world, which
is what you want for HUD elements. It opts the object out of camera transforms
and out of ancestor position accumulation.
## Prefer built-in features
Before writing custom `draw()` code, check whether the engine already covers it —
`ParticleEmitter`, `Tween`, the `ShaderEffect` presets, `UISpriteElement` and
`UITextButton`, the physics adapters, and the Tiled tilemap renderers all exist.
Hand-rolled equivalents miss the batching and the multi-backend support.
## Symptom → cause
| symptom | cause |
|---|---|
| object draws at its parent's origin, ignores its position | `draw()` not reading `this.pos` |
| pointer handler registered but never fires | `isKinematic` left at its `true` default |
| wrong draw order | anything other than `addChild(child, z)` — see section 3 |
| `me.pool: object ... cannot be recycled` while destroying | a mask on a renderable, before 20.7.0 |
| effect cannot be re-enabled after removal | `removePostEffect()` destroyed it |
| effect silently does nothing | running on the Canvas renderer |
| object appears frozen while its state changes | `update()` not returning `true` |
| off-screen object never ticks | `update()` is gated on `inViewport` — set `alwaysUpdate = true` |
| custom draw offset by half the size | centred `anchorPoint` default not zeroed |
| spatial query never finds an object | `isKinematic` left `true` — not in the broadphase |
| `z` unexpectedly 0 after a `set` | `Vector3d.set(x, y)` defaults `z` to 0 — `setPosition(x, y)` on the renderable leaves the depth alone |
| a settings option appears to do nothing | the class never reads that key — see section 7 |
| `isRenderable = false` did not hide the object | it gates bounds, not drawing — use `alpha` |
## Related skills
- `melonjs-scenes-and-state` — `Tween` and the easing families, for animating any of this
- `melonjs-getting-started` — Application lifecycle and the scene graph
- `melonjs-20-migration` — the deprecated `shader` property and other pre-20 APIs