mcpbeat Sign in

Melonjs Renderables Agent Skill

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.

3.8k tokens
context cost
SKILL.md, read when the skill is used
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
6 419
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/melonjs/melonJS --skill melonjs-renderables

The instruction itself

16 sections, as written by the author

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.

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:

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.

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.

world.addChild(sprite, 10);   // ✓ the only reliable form
  • 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.)

  • 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.

  • 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:

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.

// ✗ 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.

// ✗ 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.

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

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.

// 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.

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

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.

  • visibilityLayer decides which cameras draw a renderable: a camera draws it

only if it shares a bit with the camera's cullMask (every camera, by

default). See melonjs-camera-and-drawing for keeping something off a

minimap.

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 |

  • 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

How to use it

Copy the folder

Take melonjs/melonjs-renderables from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.