@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-depsuntil the peer range is widened).
Install
npm install @phaserjs/phaser-editor-layoutphaser 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 asthis.layout, exactly likethis.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 asobj.layoutData(cf.obj.bodyfor 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 aredesignArea(the authored design rectangle),safeArea(inset from it by the host's device insets) andvisibleArea(what the player actually sees). Make your own withcreateZone({ 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, ornone) 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
baseplusportrait/landscapeoverrides. The active variant (seethis.layout.variant) selects which overrides apply on top ofbase, 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,
setCameraFitzooms 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
reserveSafeAreaso 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.
visibleAreacan never be a camera-fit target. The fit sets the zoom, the zoom definesvisibleArea, andvisibleAreawould define the fit — a loop that diverges instead of settling (the view zooms in a little further every rendered box).setCameraFitrefuses 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 againThe 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 orientationThe 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 cameraThe 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 allMembership 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 onlySo 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.
InputPluginemits inside the window, so a handler writingobj.x = dragXwrites 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), andbox.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"withtargetZoneset 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 reservingWhat 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); // disableNo-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 everyscaleresize(window resize, orientation change, etc.). - Manually via
this.layout.refresh()after you mutateobj.layoutDataor thesafeAreamargins at run time. There is no setter to hook — fields are set directly — so an explicitrefresh()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/ybinding — 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
scaleModeis active the resolver owns the scale, and only a per-variantscaleX/scaleYbinding 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 tot.left/t.top+ offset.right/bottom→ align its right/bottom edge tot.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
setCameraFitdrives.visibleAreais 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 callsetSize(...)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
layoutBoundsinstead (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. UsescaleMode+ camera fit for the common cases. - Single built-in safe area, and it is design-space. There is one host-fed
safeAreazone (plus your custom zones), and it insets fromdesignArea, not fromvisibleArea— 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 tovisibleAreaon 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'sinsetFromoffers 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, viasetBaseRect. - Serialization is out of scope here. This package is the runtime; the editor owns
how layout data and zone references are stored in
.scenefiles.
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:
- Replace
this.layout.screenwiththis.layout.designArea. In a TypeScript project the old name is a compile error, so nothing can be missed silently. - Replace
targetType: "screen"with"designArea". The runtime falls back todesignAreafor any unrecognized target, so an old value still resolves as it did — but it is no longer a validTargetType. - 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: underENVELOPor a letterboxingsetCameraFit,designArea's edge is not the screen's edge. - 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
