npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@phaserjs/phaser-editor-layout

v2.16.0

Published

Responsive layout runtime for Phaser 3 — anchors, zones and safe areas. The runtime counterpart to Phaser Editor's responsive layout system.

Downloads

1,236

Readme

@phaserjs/phaser-editor-layout

A small responsive layout runtime for Phaser: anchor game objects to zones and device safe areas, scale them to fit, fit the whole scene with the camera, reserve space for HUDs, and swap per-orientation properties — all re-flowing automatically when the screen resizes or the device orientation changes.

It is the runtime counterpart to Phaser Editor's responsive layout system — the same resolver runs in the editor's live preview and in your game, so what you see in the editor is what you get at run time. It also works perfectly well as a standalone library without the editor.

Phaser version. The declared peer dependency is Phaser 3 (^3.60.0); the library only uses stable, long-standing Phaser APIs (scene plugins, the scale manager, cameras, getBounds) and also runs under Phaser 4 (install with --legacy-peer-deps until the peer range is widened).

Install

npm install @phaserjs/phaser-editor-layout

phaser is a peer dependency — the library uses whatever Phaser build your project already has. The declared range is Phaser 3 (^3.60.0); on a Phaser 4 project install with npm install @phaserjs/phaser-editor-layout --legacy-peer-deps.

Concepts

  • Scene plugin (this.layout). The library is a Phaser Scene Plugin, reached as this.layout, exactly like this.physics. It keeps the set of laid-out objects and re-resolves them when the screen changes.
  • obj.layoutData. Per-object layout state lives on the object as obj.layoutData (cf. obj.body for physics). Its anchor properties are split into variants (base / portrait / landscape); you configure layout by setting plain fields on them.
  • Zones. A zone is a rectangle defined by margins inset from a base rect (also split into base / portrait / landscape). The built-ins are designArea (the authored design rectangle), safeArea (inset from it by the host's device insets) and visibleArea (what the player actually sees). Make your own with createZone({ base, portrait?, landscape? }), insetting the design box or the visible one. See Zones.
  • Anchors. Per axis you pick an edge (left/center/right, top/center/ bottom, or none) plus a pixel offset. The object's matching edge/center is aligned to the target's, then nudged by the offset.
  • Variants. Both zones and layout data carry a base plus portrait / landscape overrides. The active variant (see this.layout.variant) selects which overrides apply on top of base, so one object can lay out differently per orientation.
  • Scaling. Beyond positioning, an object can be uniformly scaled to fit its target zone via scaleMode (cover / fit / fit-down). See Scaling to fit.
  • Camera fit. Instead of (or on top of) per-object work, setCameraFit zooms the main camera so a whole design zone fits the canvas — the cheapest way to scale a fixed design.
  • Boxes. A box groups objects so they move and scale together without being re-parented, so grouping costs nothing in draw order and needs no changes to finished art. See Boxes.
  • Reservations. A HUD (spin bar, win bar) can reserveSafeArea so the rest of the layout reflows around its live bounds; a box can reserve the rectangle it was authored as. See Reserving space.
  • Per-variant property bindings. Any property (texture, tint, font size, Spine skin…) can differ per variant via the bind* helpers. See Per-variant properties.

Quick start

1. Register the plugin

import Phaser from "phaser";
import { LayoutPlugin } from "@phaserjs/phaser-editor-layout";

new Phaser.Game({
    type: Phaser.AUTO,
    scale: {
        mode: Phaser.Scale.RESIZE, // so the layout follows the window/orientation
        width: 1080,
        height: 1920,
    },
    plugins: {
        scene: [
            { key: "LayoutPlugin", plugin: LayoutPlugin, mapping: "layout" },
        ],
    },
    scene: [MainScene],
});

The mapping: "layout" makes the plugin available as this.layout in every scene, and the bundled TypeScript types make this.layout and obj.layoutData strongly typed.

2. Anchor objects in a scene

export class MainScene extends Phaser.Scene {

    create() {
        // Built-in zones are always available: designArea, safeArea and visibleArea.
        // Make a custom zone with per-variant margins (top/right/bottom/left insets).
        const reelsZone = this.layout.createZone({ base: { marginTop: 360, marginRight: 90, marginBottom: 470, marginLeft: 90 } });

        // Top-centered logo, anchored to the safe area. Configure the `base` variant.
        const logo = this.add.image(0, 0, "logo");
        const logoData = this.layout.add(logo);
        logoData.base.targetType = "safeArea";
        logoData.base.horizontalAnchor = "center";
        logoData.base.verticalAnchor = "top";
        logoData.base.verticalOffset = 24;

        // Bottom-right button, 40px in from the safe-area corner.
        const hud = this.add.image(0, 0, "hud");
        const hudData = this.layout.add(hud);
        hudData.base.targetType = "safeArea";
        hudData.base.horizontalAnchor = "right";
        hudData.base.horizontalOffset = -40;
        hudData.base.verticalAnchor = "bottom";
        hudData.base.verticalOffset = -40;

        // Centered in a custom zone — "custom" is the one target that needs targetZone.
        const reels = this.add.image(0, 0, "reels");
        const reelsData = this.layout.add(reels);
        reelsData.base.targetType = "custom";
        reelsData.base.targetZone = reelsZone;
        reelsData.base.horizontalAnchor = "center";
        reelsData.base.verticalAnchor = "center";
    }
}

add() returns the object's AnchorLayoutData; you can also configure it via logo.layoutData!. Setting only base (as above) means the object lays out the same in every orientation — see Variants & orientation to vary it.

3. Feed the device safe area (optional)

The safe area is unknown at author time, so the host supplies it. On the web that's the CSS env(safe-area-inset-*) values; in a native wrapper it's a device plugin. Set the safeArea margins and re-resolve:

this.layout.safeArea.base.marginTop = insets.top;
this.layout.safeArea.base.marginRight = insets.right;
this.layout.safeArea.base.marginBottom = insets.bottom;
this.layout.safeArea.base.marginLeft = insets.left;
this.layout.refresh();

Zones: designArea, safeArea, visibleArea

Every zone is a LayoutZone and every rect is in the same scene/world coordinates; choosing a target changes which rectangle an object measures from, never which coordinate system it lives in.

| Zone | Rect | Varies with the device? | Valid camera-fit target | | --- | --- | --- | --- | | designArea | (0, 0, designSize) — the injected design size, else scale.gameSize | No — only with orientation | Yes | | safeArea | designArea minus the host's insets (per variant) | No | Yes | | visibleArea | The slice of the world the camera actually shows on the glass | Yes | No | | custom zones | Margins inset from designArea, or from the visible box (insetFrom) | Only if insetFrom: "visibleArea" | Yes, unless visible-based |

designArea is the rectangle you author against: it is a pure function of the design size, so it is the same on every device. visibleArea is what the player actually sees — the camera's view (the canvas divided by the zoom) intersected with the crop the scale mode applies. Under ENVELOP it sits inside the design rect (the sides are cropped away); under a setCameraFit that letterboxes an axis it extends past the design rect on that axis, so its x / y can be negative. That is normal.

Use designArea for content that belongs to the design, and visibleArea for anything that must touch the edge of the screen:

// A bottom bar that hugs the bottom of the glass on every device, even when the camera
// fit reveals world past the design rect.
const data = this.layout.add(bottomPanel);
data.base.targetType = "visibleArea";
data.base.horizontalAnchor = "center";
data.base.verticalAnchor = "bottom";

In a RESIZE game with no camera fit the two rectangles coincide exactly, so targeting either is the same thing.

visibleArea can never be a camera-fit target. The fit sets the zoom, the zoom defines visibleArea, and visibleArea would define the fit — a loop that diverges instead of settling (the view zooms in a little further every rendered box). setCameraFit refuses it, and any custom zone given a camera-derived base rect, with a console error; the previous fit is kept.

visibleArea needs a camera and a scale manager to exist. Without them (headless tests, or before the scene is wired up) it degenerates to designArea rather than to nothing.

Custom zones on the glass (insetFrom)

createZone insets the design rect by default. Pass "visibleArea" as the second argument and it insets the visible box instead, so a scene can hold more than one glass-tracking rectangle:

// The reels track the glass and reflow off whatever the spin column reserves...
const reels = this.layout.createZone({ base: { marginTop: 120 } }, "visibleArea");

// ...while the controls anchor to `visibleArea` itself and are left where they are.
this.layout.reserveZone(spinColumn, reels, "right");

Since a reservation belongs to one zone, everything anchored to visibleArea shares one reflow policy. A second visible-based zone is how you give part of that content a policy of its own: reserve the zone only the reflowing content reads.

"visibleArea" here means the raw camera-view box — the same rectangle the visibleArea zone insets, not that zone. So a visible-based zone inherits neither its margins nor its reservations, which is precisely the escape the parameter exists to provide; inheriting them would reproduce the problem one level down. The rule is one sentence: a zone insets either the design box or the visible box.

Such a zone is camera-dependent, so like visibleArea it can never be a camera-fit target. LayoutZone.setBaseRect remains the low-level escape hatch for any other base rectangle.

Seeing the zones, boxes and measure rects (setDebug)

A zone is an invisible rectangle, and the question it raises — where is it on this phone? — is best answered on the phone. setDebug makes a zone paint itself above everything else in the scene:

this.layout.safeArea.setDebug({ color: 0x3ddc84, label: "Safe Area" });
this.layout.visibleArea.setDebug({ color: 0xffd166, label: "Visible Area" });

const hud = this.layout.createZone({ base: { marginTop: 900 } })
    .setDebug({ color: 0xff5f6d, label: "HUD" });

hud.setDebug(false); // and back off again

The fill covers the rect the zone's consumers get; the outline is the zone at its authored size, so anything that reserved space in it shows up as the strip between the two. The outline and the label divide out the camera-fit zoom, so they stay the same size on screen however much the device scales the world.

A box answers the same method, and shares the one overlay:

const reelsBox = this.layout.createBox({ base: { x: 0, y: 0, width: 942, height: 1280 } })
    .setDebug({ color: 0xe56cff, label: "reelsBox" });

What it draws is its resolved rect — where the group ended up — outlined and labelled, and never filled: a box sits over the very art it carries, which a translucent wash would tint.

An object can be outlined too, at its measure rect (see LayoutBoundsData) — the rectangle the layout anchors, fits and reserves it by, and the source of most surprises about where something ended up. That rect belongs to the object rather than to a rect source, so the call is on the plugin:

this.layout.setBoundsDebug(spinBar, { color: 0xb07cff, label: "spinBar" });

It draws what the resolver measured: the authored layoutBounds rect where there is one, else the automatic box (a container's children unioned, a sprite's display box). No add() is needed — a marker or a reserving HUD is worth seeing whether or not it is anchored itself — and a box member is outlined where its box carries it, not where it sits in design space.

It is all purely a diagnostic — no layout changes, and nothing is allocated until the first rect asks for it. The editor generates these calls from the Debug In Game option on each zone, box and object, using the colors it paints them with in the canvas.

Anchoring inside a prefab (Parent Bounds)

A prefab is a GameObject (e.g. a Container), not a Scene, so it reaches the plugin through this.scene.layout. Use targetType: "parent" to lay children out against the prefab's own bounds:

export class CollectorPrefab extends Phaser.GameObjects.Container {

    constructor(scene: Phaser.Scene, x: number, y: number) {
        super(scene, x, y);
        this.setSize(300, 260); // containers have no intrinsic size

        const coinIcon = scene.add.image(0, 0, "coin");
        this.add(coinIcon);

        const icon = scene.layout.add(coinIcon);
        icon.base.targetType = "parent"; // resolves against this container's bounds
        icon.base.horizontalAnchor = "left";
        icon.base.horizontalOffset = 20;
        icon.base.verticalAnchor = "center";
    }
}

Variants & orientation

Both obj.layoutData and every zone carry three variants — base, portrait, landscape. base is the baseline; portrait / landscape hold only the fields that differ. The resolver uses active[prop] ?? base[prop], where the active variant is this.layout.variant.

Pick the active variant in one of two ways (default is manual, "base"):

// Manual:
this.layout.setVariant("portrait");

// Or orientation-driven — follows the screen on every resize:
this.layout.autoVariant(true);

Author once, and rotating re-flows automatically:

// Safe area: notch on top in portrait, on the left in landscape.
this.layout.safeArea.portrait.marginTop = 120;
this.layout.safeArea.landscape.marginLeft = 120;

// A button: bottom-center in portrait, right-center in landscape.
const data = this.layout.add(button);
data.base.targetType = "safeArea";
data.portrait.horizontalAnchor = "center";
data.portrait.verticalAnchor = "bottom";
data.portrait.verticalOffset = -40;
data.landscape.horizontalAnchor = "right";
data.landscape.horizontalOffset = -40;
data.landscape.verticalAnchor = "center";

this.layout.autoVariant(true);

See the mobile-orientation example for the full picture.

Scaling to fit (scaleMode)

Anchoring positions an object; scaleMode additionally scales it uniformly to fit its target zone (the same zone the anchor targets). It is a per-variant AnchorProps field:

  • "none" (default) — never scaled.
  • "fit" — scale to sit inside the zone, touching it on the tighter axis. Grows as well as shrinks, so the object fills the room the zone gives it.
  • "fit-down" — as "fit", but shrink only: never upscales beyond the authored scale, so an object already inside its zone is left at its authored size.
  • "cover" — scale to fill the zone (may overflow).

All three preserve the aspect ratio. "fit" and "cover" both scale in either direction and differ only in which axis they satisfy: "fit" takes the smaller factor so nothing spills out, "cover" the larger so no gap is left. Reach for "fit" when the zone is the room you are giving the content and you want it used; for "fit-down" when the authored size is the intended size and shrinking is only a fallback on cramped screens.

const data = this.layout.add(reels);
data.base.targetType = "safeArea";
data.base.horizontalAnchor = "center";
data.base.verticalAnchor = "center";
data.base.scaleMode = "fit-down"; // shrink the reels to fit the safe area in any orientation

The scale stage runs before anchoring, so the object is aligned at its final size. It is idempotent across passes (scaling is always computed from the authored base scale).

The authored base scale is the object's scale as your scene set it, plus any per-variant scaleX / scaleY binding — the factor multiplies onto that. While a scale mode is active, scale written from anywhere else (a tween, game code) is not taken as a new base: a resize landing mid-tween would otherwise make that tick of the animation the object's authored size for the rest of the session. Which means a scale mode owns the property: the base is captured when the object is added, and to change it afterwards, author it per variant with bindProperty(obj, "scaleX", …). Objects with scaleMode: "none" keep following whatever scale the game sets — nothing is multiplied onto it, so there is no base to protect. Containers (which have no intrinsic size) are measured by the union of their children, so fit / fit-down / cover work on composite objects and prefab instances too — see Measuring to declare a box explicitly.

Measuring: which box the layout uses

Anchoring, scaleMode and reservations all align, fit and inset by an object's layout box. By default it is measured from the live scene: the union of a container's children, or the display size + origin of a sprite. That measures what is, not what the layout means — a container's children often reach past its visible rect (a masked reel strip, an animation layer), and a texture can carry transparent padding, so the object ends up anchored and scaled by a box the player never sees.

obj.layoutBounds declares the box instead. It needs no add(): the resolver reads it off whatever it meets while measuring, so it works on objects deep inside a prefab. Like layoutData it is per-variant (base / portrait / landscape, merged variant ?? base), because a rect authored against one variant's art is wrong once another variant swaps it.

// This object's own box: a rect in its local space, projected through its world matrix
// (so it follows position, rotation, scale and parent containers).
sprite.layoutBounds = { base: { mode: "manual", x: -50, y: -50, width: 100, height: 100 } };

// Or: let a child define its parent's box. The parent measures the flagged child (or
// children) instead of unioning everything — how a prefab declares its visible rect once,
// for every instance.
maskRect.layoutBounds = { base: { defineParentBounds: true } };

The rule is recursive, so declarations compose: a container's box is the union of its children's measured boxes, and each child that declares one stops the walk there. Three reel prefabs that each declare their masked rect therefore give the parent that rect, not the symbols' — and because the walk stops early, it is cheaper than a full getBounds().

A flagged or manual box is honored even when the object is visible === false (the natural marker rectangle is hidden). mode: "manual" needs a positive width and height, otherwise the object falls back to automatic.

The automatic union itself skips children that contribute nothing: hidden children, and degenerate 0x0 boxes (Phaser's Rectangle.Union treats those as a point, which would drag the box toward the world origin). Masks are not consulted: declare the box instead, so the editor and the runtime measure the same thing.

Camera fit

For a fixed-design scene, the highest-quality way to scale to the display is to zoom the camera rather than each object. setCameraFit(zone?) zooms the main camera so zone (default safeArea) fits the live canvas, then centers on it:

this.layout.setDesignSize(1080, 1920); // author size the design was built at
this.layout.setCameraFit();            // fit the safe area; re-applied every pass
// this.layout.setCameraFit(false);    // disable and reset the camera

The zoom is min(canvasWidth / zoneWidth, canvasHeight / zoneHeight). Pair it with a fixed design size (via setDesignSize, or the game config width/height under FIT/ENVELOP).

The target must not depend on the camera. visibleArea — and any custom zone given a camera-derived base rect — is refused with a console error, because the fit would set the zoom, the zoom would redefine the zone and the fit would never converge. Fit designArea, safeArea or a custom zone, and anchor to visibleArea the objects that need to reach the edge of the resulting view.

Boxes: grouping without re-parenting

A box moves and scales a set of objects together, treating them as one composition, without owning them in the display list.

That last part is the point. In Phaser, container.add(child) does two unrelated things at once: it shares a transform and it rewrites draw order. One mechanism for two concerns means you get a rigid group or an arbitrary interleave, never both — and when the layout phase comes last (as it usually does), re-parenting finished art to get the group is exactly what the project cannot afford. A box groups by reference, so nothing renders in a different order because of it:

const reels = this.layout.createBox({
    portrait:  { x:  62, y: -206, width: 942, height: 1280 },
    landscape: { x: 529, y: -206, width: 942, height: 1280 },
});

// A box anchors and scales itself exactly as an object does.
reels.layoutData.base.targetType = "safeArea";
reels.layoutData.base.horizontalAnchor = "center";
reels.layoutData.base.verticalAnchor = "center";
reels.layoutData.base.scaleMode = "fit-down";

reels.addMember(this.reelsContainer);
reels.addMember(this.jackpotBar);      // a sibling, elsewhere in the tree
reels.addMember(this.underSymbolsFx);  // promoted into a Layer: no parent at all

Membership is per variant. The second argument takes the same { base, portrait?, landscape? } values every other per-variant setting does — an unset variant inherits base, and a variant resolving to false opts the object out there:

reels.addMember(this.sideBanner, { base: false, landscape: true });  // landscape only
reels.addMember(this.jackpotBar, { base: true, landscape: false });  // portrait only

So a composition can differ between orientations without anything moving in the display list: an object rides a stacked box in portrait and a wide one in landscape, and each box is simply inert in the other variant. An object is transported by one box per variant, so two boxes claiming the same variant for the same object is the move it has always been — the later declaration wins that variant, and the earlier box keeps the rest.

The box's authored rect is the rectangle its members were laid out inside when the scene was built. The box anchors and scales that rect into its target, and its members are moved by the map from the authored rect to the resolved one — so the composition arrives as a whole, at the right place and the right size, whatever the tree looks like.

Members can sit anywhere: top-level, deep inside containers, or promoted into a Phaser.GameObjects.Layer (which re-orders drawing and leaves parentContainer null). Each one is mapped in world space and converted back through its own parent, so a box spanning three tree depths behaves like one spanning none.

Design space and display space

A box does not hold the map on its members permanently. It applies it on POST_STEP and takes it back off on POST_RENDER, so:

During the update half of a game step every member is in design space. During the render half it is in display space.

Everything your game does — update, tweens, timers, physics, your own maths — runs in the update half and therefore only ever meets design space: the coordinates the scene was authored in. Nothing needs to know a box exists. A pulse tween on a boxed coin writes the scale it computed, the box borrows it for one render and gives it back, and the tween reads its own value again next step.

The window is exact rather than approximate. Phaser reads object transforms in precisely two places, and both are inside it: InputManager hit-tests from PRE_RENDER, and the renderer reads x / y / scale off the object as it draws. So input agrees with what you see, including for the top-level and layer-promoted objects that boxes exist for.

Two consequences worth knowing:

  • A drag works, and is adopted. InputPlugin emits inside the window, so a handler writing obj.x = dragX writes a display-space coordinate. That write is mapped back to design space and kept, so the object stays under the pointer and stays where you dropped it.
  • If you compute a position from a boxed object during the update half, it is in design space. Use box.toDisplay(x, y) when you need where it will actually draw (placing a non-member popup over a boxed reel, say), and box.toDesign(x, y) for the way back.

Two rules

  • A box's rect never depends on its members. It is authored and resolves against zones only. That is what lets a member anchor inside its own box — targetType: "parent", which reads as what I am inside and picks the box over the container — without the box chasing it back.
  • Boxes are flat. A box is never a member of another box, so every object is moved by exactly one map. A box may still anchor to another box (targetType: "custom" with targetZone set to it), which covers what nesting would be reached for; those resolve in dependency order. An object handed to a second box is moved, not shared.

An object may be a box member and be anchored itself; the two compose, in that order. A member whose ancestor is in the same box is moved once — the ancestor carries it.

Reserving space (for HUDs)

A HUD that must not overlap the play field — a bottom spin bar, a top win bar — can reserve an edge of a zone. Each pass, the object's live bounds are subtracted from that edge, and everything else (camera-fit and anchored objects) reflows around it:

// Reserve the safe area's bottom edge for the spin bar (defaults to "bottom").
this.layout.reserveSafeArea(spinBar);

// Or a specific edge, per variant — reserve the bottom in portrait, nothing in landscape:
this.layout.reserveSafeArea(sideBar, { base: "bottom", landscape: "none" });

// Any zone, any edge:
this.layout.reserveZone(winBar, this.layout.safeArea, "top");

// The zone is per-variant too: take the space out of the safe area in portrait, and out of
// the glass in landscape.
this.layout.reserveZone(winBar,
    { base: this.layout.safeArea, landscape: this.layout.visibleArea }, "top");

this.layout.unreserveZone(spinBar); // stop reserving

What is taken out of the zone is the object's overlap with it — how far it reaches in from that edge, not how big it is. For a bar sitting against the edge the two are the same number, so this is invisible most of the time, but it is what makes the awkward cases come out right: a bar held clear of the edge by an offset blocks the gap beneath it too, and one that only dips into the zone blocks only the part that dips.

The reserving object is immune to its own reservation (it anchors against the unreserved edge), so a bottom bar stays pinned to the true bottom; so is the container it lives in, so a HUD is never pushed by the space its own children reserve. Reservations auto-remove on the object's DESTROY.

Reserving a zone you are not anchored to

Because it is the overlap that counts, the reserver does not have to live in the zone it reserves. That is how you get a bar that hugs the glass and keeps off the play field:

// The bar anchors to the visible area, so it touches the bottom of the screen on any device.
spinBar.layoutData = { enabled: true, base: { targetType: "visibleArea", verticalAnchor: "bottom" } };

// ...but it reserves the safe area, which is what the play field anchors to.
this.layout.reserveSafeArea(spinBar, "bottom");

On a device where the visible area runs past the design rect, the bar sits partly (or wholly) below the safe area, and only the part that actually intrudes is reserved — so the field reflows by exactly what it has to and no more.

Stacking several reservations on one edge

Objects reserving the same edge of the same zone form a stack, and each one is placed within it: a reserver anchors against the zone already inset by the reservations before it, so the second bar sits on top of the first and follows its live height — no hand-computed offsets. The optional order argument places the object in the stack, counting from the edge inwards (0, the default, sits against the edge). Equal orders keep registration order.

// The spin bar hugs the bottom; the win bar rides on top of it, whatever height it has.
this.layout.reserveSafeArea(spinBar, "bottom", 0);
this.layout.reserveSafeArea(winBar, "bottom", 1);

// Per-variant, like `edge`: stacked in portrait, the other way round in landscape.
this.layout.reserveSafeArea(winBar, "bottom", { base: 1, landscape: 0 });

Everything that does not reserve the edge reflows around the whole stack — the zone gives up as much as the deepest member of it reaches in, which for bars sitting flush on each other is simply their combined height. A reserver is exempt only on the edge it competes on: a bottom bar still reflows away from a left reservation.

A box can reserve, with the rect you drew

A box may reserve as well, and hands over its resolved rect instead of a measurement:

// The reels group takes the bottom of the play zone: exactly the rectangle authored for the
// box, per variant, whatever the art inside it measures.
this.layout.reserveZone(reelsBox, playZone, "bottom");

This is usually what you want for a group. An object reserves what its art happens to measure, and the union of a container's subtree counts the masked strips and animation layers that reach past the visible edge — so reserving with a group of objects means making it a real container and then correcting the measurement with a manual layoutBounds rect. A box's rect is authored, so the reserved strip is a number you set.

Everything else is unchanged: the overlap rule, the per-variant edge and order, and one shared stack per edge (a box and a bar reserving the same edge stack against each other). A box is exempt from the zone it reserves, as an object is, and so are its members — nothing is pushed away by the space its own group reserves. Since boxes exist before any game object, a box wins a tie on equal order; pass an explicit order to say otherwise. A box has no DESTROY event, so a box reservation lasts until unreserveZone or scene shutdown.

Per-orientation game size

For a fixed-design game under FIT / ENVELOP, one authored size gets letterboxed or cropped in the opposite orientation. autoGameSize gives portrait and landscape each a world shaped like their viewport, calling scale.setGameSize(...) for the live orientation on every resize:

this.layout.autoGameSize({
    base:      { width: 920, height: 1600 },
    portrait:  { width: 920, height: 1600 },
    landscape: { width: 1600, height: 920 },
});
this.layout.autoVariant(true); // usually paired, so the layout variant switches too
// this.layout.autoGameSize(null); // disable

No-op while a design size is injected (the editor preview owns sizing there), and guarded against redundant resizes and their feedback.

The game size is global but this call is per-scene, so a scene only writes it while it is the one the player is on. Only SHUTDOWN detaches the resize listener, so a scene that is asleep, paused or transitioning out is still asked on every resize; it stops answering, and a slept or paused scene reclaims the world on WAKE / RESUME against whatever the viewport became meanwhile. Scenes also overlap without any of those states being set — scene.transition() runs both for the length of the fade, as does scene.launch() — so a scene's own setGameSize resize never prompts another scene to re-apply its size, and the arriving scene's world survives. Beyond that the library cannot pick a winner between two scenes that genuinely share the screen under different sizes: give them the same sizes, or let only one declare them.

Per-variant property bindings

Anchoring/scaling covers geometry; to vary other properties per orientation, bind them. On every pass (and immediately) the plugin resolves values[variant] ?? values.base and applies it. Bindings are keyed by target + property, auto-removed on DESTROY.

// Generic field assignment (obj[key] = value):
this.layout.bindProperty(title, "visible", { base: true, landscape: false });
this.layout.bindProperty(bg, "tint", { base: 0xffffff, portrait: 0xffcc00 });

// Applied through a setter method instead of a field:
this.layout.bindTexture(button, { base: { key: "btn" }, landscape: { key: "btn_wide" } });
this.layout.bindOriginX(label, { base: 0.5, landscape: 0 });
this.layout.bindMethod(scoreText, "setFontSize", { base: 48, portrait: 32 });

// Spine (no need to import the Spine plugin):
this.layout.bindSkin(hero, { base: "default", landscape: "wide" });
this.layout.bindSkeleton(hero, { base: "hero", landscape: "hero_alt" }, "hero-atlas");

this.layout.unbind(title, "visible"); // remove one binding (or unbind(obj) for all)

When does layout resolve?

  • Automatically once after the scene's create, and on every scale resize (window resize, orientation change, etc.).
  • Manually via this.layout.refresh() after you mutate obj.layoutData or the safeArea margins at run time. There is no setter to hook — fields are set directly — so an explicit refresh() is how changes take effect immediately.

refresh() always runs a full, ordered pass (parents before children), so a child anchored to its parent always sees the parent's resolved position. The pass is just arithmetic over the registered objects, so it is cheap.

A pass is not a pure function of the screen, though: a reservation's depth and a container's layout box are measured from the very objects the pass then moves. So refresh() repeats the pass until the transforms stop moving (bounded, so a layout authored as a cycle cannot hang the game). This is what makes an orientation change land in one go instead of leaving the scene a rotation behind, and layoutupdate still fires once at the end.

Each pass also starts by putting every object back on its authored transform, so the result never depends on what the previous pass wrote:

  • An axis (or a scaleMode) the active variant leaves free returns to the authored value, rather than keeping the coordinate the other variant's anchor set. Rotating back and forth is lossless.
  • Anything that moves an object from outside the resolver — game code, a tween, an x / y binding — is adopted as the new authored position, so a free axis still follows it while an anchored axis still wins.
  • Scale is the exception. While a scaleMode is active the resolver owns the scale, and only a per-variant scaleX / scaleY binding can re-author the base it multiplies its factor onto — see Scaling to fit.

A pass always reads design space, so a refresh() called from inside the render window (an input handler's, since input runs there) closes the window first and re-opens it after — see Design space and display space.

Reacting to layout

The plugin fires layoutupdate after each pass:

import { LAYOUT_UPDATE_EVENT } from "@phaserjs/phaser-editor-layout";

this.layout.on(LAYOUT_UPDATE_EVENT, () => {
    // reposition non-layout decorations, redraw overlays, etc.
});

this.layout.on/once/off delegate to the plugin's events emitter (this.layout.events).

Checking which build you run

Several copies of this library tend to coexist — a game's npm dependency, the editor's vendored bundle, a side-loaded dist — and an old one does not fail loudly: it simply ignores arguments it does not know yet (a reserveZone order, say). LayoutPlugin.VERSION tells you which build is live, and LayoutPlugin.DEBUG traces what the reservation stacks resolved to:

import { LayoutPlugin } from "@phaserjs/phaser-editor-layout";

LayoutPlugin.DEBUG = true; // before the first scene boots
[phaser-editor-layout] v2.0.0 booted on scene "MainScene"
[phaser-editor-layout] v2.0.0 portrait reservations (insets t0.0 r0.0 b420.0 l0.0):
  bottom slot 0.0px in <- "guiComposition" (order 0, 300.0px thick)
  bottom slot 300.0px in <- "bonusExtraBets" (order 1, 120.0px thick)

Reservations are traced only when the outcome changes, so a resize storm stays readable. DEBUG is off by default and logs nothing in production.

API

this.layout (LayoutPlugin)

| Member | Description | | --- | --- | | static VERSION: string | The version of the build you are running. Also exported as VERSION. | | static DEBUG: boolean | Trace the version and the reservation stacks to the console. Off by default. See Checking which build you run. | | safeArea: LayoutZone | The design area inset by the device safe-area insets. Set safeArea.base.margin* (or per-variant) from the host. | | designArea: LayoutZone | The authored design rectangle, (0, 0, designSize). Does not vary with the device. | | visibleArea: LayoutZone | What the player actually sees (camera view ∩ scale-mode crop). Varies with the device; never a camera-fit target. See Zones. | | variant: VariantName | The active variant ("base" \| "portrait" \| "landscape"). Defaults to "base". | | add(obj): AnchorLayoutData | Enable layout for obj: attaches a default obj.layoutData, registers it, and returns the data to configure. | | remove(obj): void | Stop laying out obj and clear its layoutData. | | createZone(variants?, insetFrom?): LayoutZone | Create a custom zone, optionally setting per-variant margins ({ base?, portrait?, landscape? }, each a partial of the four margin* sides). insetFrom picks the box those margins inset: "designArea" (default) or "visibleArea" (the raw camera box — see Custom zones on the glass). Assign it to data.<variant>.targetZone with targetType: "custom". | | createBox(rects?): LayoutBox | Create a box with per-variant authored rects ({ base, portrait?, landscape? }, each a partial { x, y, width, height }). Configure box.layoutData to anchor/scale it, then addMember the objects it moves. See Boxes. | | boxes: ReadonlySet<LayoutBox> | The boxes created on this scene. | | boxOf(obj): LayoutBox \| undefined | The box moving obj in the active variant, if any. Membership is per variant, so a rotation can change the answer. | | applyBoxes() / restoreBoxes() | Put every box member into display space / back into design space. Driven automatically from POST_STEP / POST_RENDER; call them only in a host with no game loop (the editor's preview). Idempotent. | | manualBoxWindow(): this | Never hook the game loop: the host drives applyBoxes() / restoreBoxes() itself. For a host that owns the render, or whose plugin does not live as long as the game — a game never needs it. Call it before creating any box. | | manualResolve(): this | Stop the configuration calls (setDesignSize, setVariant, reserveZone, unreserveZone, …) from re-resolving on their own, leaving every pass to the host's refresh() / resolveList(). For a host that resolves explicitly and owns the plugin's lifetime — a game never needs it. | | setVariant(variant): void | Manually set the active variant (turns off auto) and re-resolve. | | autoVariant(enabled?): void | Follow the screen orientation on every resize (portrait / landscape); disabling resets to "base". | | get orientation() | The screen's current orientation ("portrait" if height > width, else "landscape"). | | get designSize() | The injected fixed design size, or null. | | setDesignSize(w, h): void / clearDesignSize() | Set / clear the fixed design size zones and camera-fit are computed against (the editor preview injects this). | | autoGameSize(sizes \| null): void | Reshape the game world (scale.setGameSize) per orientation; null disables. See Per-orientation game size. | | setCameraFit(zone?): void | Zoom/center the main camera to fit zone (default safeArea); false/null disables. A camera-dependent zone (visibleArea) is refused. See Camera fit. | | reserveZone(obj, zone?, edge?, order?): void | Reserve an edge of zone (default safeArea, "bottom") for obj's live bounds; order is its slot in that edge's stack (0 = against the edge). All three may be per-variant. obj may be a LayoutBox, which reserves its authored (resolved) rect instead of a measurement. | | reserveSafeArea(obj, edge?, order?): void | Sugar for reserveZone against safeArea. | | unreserveZone(obj): void | Stop reserving space for obj (a game object or a box). | | bindProperty(obj, key, values, apply?): void | Bind a property to per-variant values (values[variant] ?? base), applied as obj[key] = value or via apply. | | bindTexture / bindOriginX / bindOriginY / bindMethod / bindSkin / bindSkeleton | Per-variant binders that apply through the matching setter (setTexture, setOrigin, obj[method](v), Spine skin/skeleton). | | unbind(obj, key?): void | Remove one binding (key) or all bindings for obj. | | setBoundsDebug(obj, true \| false \| { color?, label? }): void | Outline obj's measure rect in the game so it can be seen on a device — see Seeing the zones, boxes and measure rects. Needs no add(). | | refresh(): void | Re-resolve every enabled object now (full, ordered pass). | | resolveList(objects): void | Resolve a specific, pre-ordered list of objects (used by the editor preview). | | events: Phaser.Events.EventEmitter | Emits layoutupdate after each pass. | | on/once/off(...) | Delegates to events. |

AnchorLayoutData (obj.layoutData)

| Field | Type | Description | | --- | --- | --- | | enabled | boolean | When false, the object keeps its raw x/y and is skipped ("Absolute" mode). | | base | AnchorProps | Baseline anchor properties; the resolver falls back here for unset variant fields. | | portrait | AnchorProps | Overrides applied when variant === "portrait". | | landscape | AnchorProps | Overrides applied when variant === "landscape". |

Defaults from add(obj): enabled: true, base = { targetType: "designArea", anchors "none", zero offsets }, empty portrait / landscape — i.e. inert until you choose anchors.

AnchorProps (data.base / data.portrait / data.landscape)

All fields optional; on portrait / landscape an unset field inherits from base.

| Field | Type | Description | | --- | --- | --- | | targetType | "designArea" \| "safeArea" \| "visibleArea" \| "parent" \| "custom" | What to anchor to: the design rectangle, the device safe area, what the player actually sees, what the object is inside (its box if it rides one, else its parent container, else the design rect), or a custom zone. The old "frame" spelling still loads and means "parent". See Zones and Boxes. | | targetZone | ILayoutRect? | The zone to anchor to when targetType === "custom". Any ILayoutRect works, a box included (it contributes its resolved rect), but the Scene Editor only authors zones — anchoring to a box is object-to-object anchoring, which it does not offer. | | horizontalAnchor | "left" \| "center" \| "right" \| "none" | Horizontal anchor edge. none keeps the current x. | | horizontalOffset | number | Pixel offset applied after the horizontal anchor. | | verticalAnchor | "top" \| "center" \| "bottom" \| "none" | Vertical anchor edge. none keeps the current y. | | verticalOffset | number | Pixel offset applied after the vertical anchor. | | scaleMode | "none" \| "cover" \| "fit" \| "fit-down" | Uniformly scale the object to fit its target zone before anchoring. none (default) leaves scale alone. See Scaling to fit. |

LayoutBoundsData (obj.layoutBounds)

Declares the box the resolver measures for this object. All three variants are optional and merge as variant ?? base; needs no add(). See Measuring.

| Field | Type | Description | | --- | --- | --- | | base | BoundsProps? | Baseline measure-box properties. | | portrait | BoundsProps? | Overrides applied when variant === "portrait". | | landscape | BoundsProps? | Overrides applied when variant === "landscape". |

BoundsProps

| Field | Type | Description | | --- | --- | --- | | mode | "auto" \| "manual" | auto (default) measures the live scene: a container's children, or a sprite's display box. manual uses the rect below. | | x / y / width / height | number | The manual box, in the object's local space, projected through its world matrix when measured. Ignored (falls back to auto) unless width and height are positive. | | defineParentBounds | boolean | Make the parent container measure this object instead of unioning all of its children. Flagged siblings union together; a flagged object is measured even when hidden. |

LayoutZone

| Member | Description | | --- | --- | | base: { marginTop, marginRight, marginBottom, marginLeft } | Baseline insets from each edge of the base rect, in pixels. | | portrait / landscape | Partial margin overrides for those variants; unset sides inherit base. | | setMargins({ base?, portrait?, landscape? }): this | Set margins for one or more variants in a single call, merging into what's there (only supplied sides change). Returns the zone for chaining. Handy for the built-in safeArea / designArea. | | setBaseRect(provider, cameraDependent?): this | Inset from a rectangle other than the design rect (null restores the default). cameraDependent marks the rect as camera-derived, which bars the zone from being a camera-fit target — as the built-in visibleArea is. For the camera box itself, prefer createZone(variants, "visibleArea"). | | cameraDependent: boolean | Whether this zone's rect depends on the camera, so setCameraFit refuses it. | | getRect(out?): Phaser.Geom.Rectangle | The effective rect at the current base rect and active variant. Pass out to avoid allocations. | | setDebug(true \| false \| { color?, alpha?, label? }): this | Draw the zone in the game so it can be seen on a device — see Seeing the zones, boxes and measure rects. |

LayoutBox

Created by createBox. See Boxes.

| Member | Description | | --- | --- | | layoutData: AnchorLayoutData | How the box itself anchors and scales into its target — the same per-variant AnchorProps an object uses. | | base / portrait / landscape: BoxRect | The authored rect ({ x?, y?, width?, height? }) per variant; unset fields inherit base. | | setRects({ base?, portrait?, landscape? }): this | Set authored rects for one or more variants in a single call, merging into what's there. | | addMember(obj, variants?): this | Start moving obj with this box. Not a re-parenting: draw order is untouched. variants ({ base, portrait?, landscape? } of booleans) declares which variants carry it — every one when omitted, an unset variant inheriting base, a resolved false opting out. An object belongs to one box per variant, so this takes the variants they share out of any other box. | | removeMember(obj): void | Stop moving obj in every variant, leaving it on its design transform. | | hasMember(obj): boolean / members: ReadonlySet | Membership in any variant. | | memberVariants(obj): ReadonlySet<VariantName> \| undefined | The variants obj is carried in, or undefined when it is not a member. | | designRect(out?): Phaser.Geom.Rectangle | The authored rect for the active variant: the box members were authored inside, and what a member's targetType: "parent" resolves against. | | getRect(out?): Phaser.Geom.Rectangle | The resolved rect: where the authored rect ended up. What anchoring to this box sees, and what it contributes when it reserves. | | factor: number | The uniform factor from the authored rect to the resolved one (1 without a scaleMode). Members take it on top of their own scale. | | toDisplay(x, y, out?) / toDesign(x, y, out?) | Map a point between design space and display space — see Design space and display space. | | mapRect(rect) | Map a rectangle into display space, in place. | | setDebug(true \| false \| { color?, label? }): this | Outline the box's resolved rect in the game so the group can be seen on a device — see Seeing the zones, boxes and measure rects. |

Resolution semantics

Given the target rect t and the object size w×h (its displayWidth / displayHeight), each axis aligns the object's edge/center to the target's, accounting for the object's origin:

  • left / top → align the object's left/top edge to t.left/t.top + offset.
  • right / bottom → align its right/bottom edge to t.right/t.bottom + offset.
  • center → align its center to the target's center + offset.
  • none → leave that axis on the object's authored position (see When does layout resolve?).

For centered-origin objects (origin = 0.5) this reduces to the familiar x = t.left + offset + w / 2 form.

Each object is resolved in two stages: scale (if scaleMode !== "none") then anchor, so alignment uses the object's final size. Containers (and prefab instances) have no intrinsic size, so both stages measure them by their layout box — the union of their children, or whatever layoutBounds declares (see Measuring). The box edge/center is aligned to the target, so content never pokes outside the zone.

The properties used (targetType, anchors, offsets, scaleMode) and the zone rect t are first resolved for the active variant as active[prop] ?? base[prop], so portrait/landscape overrides and per-variant zone margins both feed into the same alignment math.

Notes & current limitations

  • Camera. Zone rects are world coordinates, which is exactly what setCameraFit drives. visibleArea is the one zone that reads the camera back, and it does so once per pass right after the fit — a camera you scroll or zoom yourself between passes is not special-cased.
  • Parent Bounds geometry. With targetType: "parent" on an object that rides no box, the parent container's rect is taken as (0, 0, parent.width, parent.height) in its local space, so call setSize(...) on the container. Children are aligned within that rect; parent rotation is not specially handled. A box member takes its box's authored rect instead — the box is the closer box, and its map runs after the anchor either way.
  • Masks are not measured. A masked child still contributes its full box to an automatic union; declare the box with layoutBounds instead (the same rect the mask is built from). This keeps what the editor can draw and what the runtime measures identical.
  • No non-uniform stretch. Scaling is uniform (fit-down / cover); a single axis "fill width minus margins" stretch is not expressible. Use scaleMode + camera fit for the common cases.
  • Single built-in safe area, and it is design-space. There is one host-fed safeArea zone (plus your custom zones), and it insets from designArea, not from visibleArea — the margins are authored against the design resolution. A single object that both hugs the glass and respects the notch is therefore still not expressible: anchoring to visibleArea on a notched phone lands content under the notch. Keeping a glass-hugging bar and a safe-area play field out of each other's way is expressible, by reserving across the two zones — see "Reserving a zone you are not anchored to".
  • A zone's base rect is only authorable as one of two boxes. createZone's insetFrom offers the design box and the visible box, which is what the editor authors. Any other base rectangle — a zone inset from the safe area, or from another zone — is runtime-only, via setBaseRect.
  • Serialization is out of scope here. This package is the runtime; the editor owns how layout data and zone references are stored in .scene files.

Migrating from 1.x

2.0.0 renames the zone that used to be called screen and adds one new built-in. The name was accurate when it was chosen — the runtime resolved on Scale.RESIZE, where the game size is the viewport — but setDesignSize, autoGameSize and setCameraFit each broke that identity, and screen quietly came to mean "the design rectangle" while everyone read it as "the device". The two meanings are now two zones. There is no screen alias.

| 1.x | 2.0 | | --- | --- | | this.layout.screen | this.layout.designArea | | — | this.layout.visibleArea (new: what the player actually sees) | | targetType: "screen" | targetType: "designArea" | | — | targetType: "visibleArea" |

To migrate:

  1. Replace this.layout.screen with this.layout.designArea. In a TypeScript project the old name is a compile error, so nothing can be missed silently.
  2. Replace targetType: "screen" with "designArea". The runtime falls back to designArea for any unrecognized target, so an old value still resolves as it did — but it is no longer a valid TargetType.
  3. Re-target the objects that should hug the edge of the screen (a bottom bar, a full-bleed background) to "visibleArea". This is the behavior change worth having: under ENVELOP or a letterboxing setCameraFit, designArea's edge is not the screen's edge.
  4. If you fit the camera, check the target is not visibleArea — it is refused, with a console error explaining why (see Camera fit).

Phaser Editor users author all of this in the editor: opening a scene migrates the stored targetType and camera-fit target, and regenerating the code emits the new names.

License

MIT