Responsive Canvas
The canvas is responsive by default. RendererPlugin tracks a host
element and re-maps the virtual rectangle on every resize — no extra config
needed. To pin the canvas at a fixed size, give the container fixed CSS
dimensions; the canvas will track that constant size.
import { RendererPlugin } from "@yagejs/renderer";
new RendererPlugin({ width: 800, height: 600, container: document.getElementById("game")!, // fit: { mode: "letterbox" } — this is the default});Pass fit to override the mode or the observed element:
import { RendererPlugin } from "@yagejs/renderer";
const host = document.getElementById("game")!;
new RendererPlugin({ width: 800, height: 600, container: host, fit: { mode: "cover" }, // change mode // fit: { mode: "letterbox", target: other } // observe a different element});Four modes cover the usual web-game needs:
letterbox(default) — preserves the virtual aspect ratio and centers it inside the host. Leftover space around the game is painted withbackgroundColor(the “bars”). This is what most games want: no distortion, no cropping.expand— same scaling asletterbox(virtual is always fully visible), but the game draws into the bars instead of leaving them blank. Pair withextendedVirtualRectsto render fog, parallax, or a decorative backdrop into the play-adjacent space. Matches Godot’sexpand, Unity’sExpandmatch mode, and Construct 3’s “Scale inner.”cover— preserves aspect and fills the host edge-to-edge, CSS-cover style. Whichever axis has the wider host aspect ratio gets overflow clipped by the canvas boundary. Rarely the right choice for gameplay — aspect ratio changes what the player can see. Good for full-bleed backgrounds or splash screens.stretch— scales each axis independently to fill the host. Distorts the image; use sparingly (menus, editor panels, deliberate stylistic effects).
letterbox and expand apply the exact same stage transform. The
difference is a rendering convention: under letterbox the bars are the
flat background color, under expand the game is expected to fill them.
fit.target defaults to the container you passed, or the canvas’
parentElement. There is no document.body fallback — a ResizeObserver
on body fires on any page layout change, not just viewport resizes. With
no host to observe, the fit applies once against the configured
width × height. Pass fit: { target: document.body } to opt in, or name
any other element. setFit({ mode }) changes the mode and keeps the target
already in use.
Under the hood the plugin uses a ResizeObserver and calls
renderer.resize(hostW, hostH) on each change, so the backing buffer stays
hi-DPI-correct via resolution + autoDensity. Stage scale and position
are recomputed to map the virtual rectangle into the new canvas per the
active mode. In headless environments (no DOM target) the plugin applies a
one-shot transform against the initial width × height and installs no
observer.
At runtime you can switch modes or observe a different element:
import { RendererKey } from "@yagejs/renderer";
const renderer = this.use(RendererKey);const [cssX, cssY] = [120, 80]; // a point on the canvas, in CSS pxconst [x, y] = [400, 300]; // a point in virtual space
renderer.setFit({ mode: "expand" }); // swap modes / targetrenderer.fit; // current { mode, target? }renderer.canvasSize; // { width, height } in CSS pxrenderer.canvasToVirtual(cssX, cssY); // invert the stage transformrenderer.virtualToCanvas(x, y); // forward transform (virtual → CSS px)renderer.visibleVirtualRect; // on-screen sub-rect of virtual spacerenderer.croppedVirtualRects; // virtual regions off-screen under coverrenderer.virtualCanvasRect; // where virtual sits on canvas (CSS px)renderer.visibleCanvasRect; // full canvas extent in virtual pxrenderer.extendedVirtualRects; // bars outside virtual (letterbox/expand)HUD anchoring under cover
Section titled “HUD anchoring under cover”In letterbox / expand / stretch the full virtual rectangle is always
on-screen, so HUDs anchored to virtualSize corners stay visible. Under
cover the long axis gets cropped — a HUD anchored to virtual (0, 0)
can end up off-screen. renderer.visibleVirtualRect returns the
currently-visible sub-rect of virtual space (clamped to virtual bounds),
so HUD code can track what the player actually sees while gameplay keeps
operating in the full declared virtual space:
import { Transform } from "@yagejs/core";import { RendererKey } from "@yagejs/renderer";
const renderer = this.use(RendererKey);const scoreLabel = this.spawn("score").add(new Transform());
// Gameplay: always the full declared play area.const { width, height } = renderer.virtualSize;
// HUD: follow the visible sub-rect so corner-anchored elements stay on-screen.const visible = renderer.visibleVirtualRect;scoreLabel.setPosition(visible.x + 16, visible.y + 16);Under letterbox / expand / stretch visibleVirtualRect equals
{ x: 0, y: 0, width: virtualWidth, height: virtualHeight } — no change
needed for non-cover games. This distinction matters for competitive
titles where a wider viewport must not let players see more of the play
area than narrower ones do.
Drawing into the bars under expand
Section titled “Drawing into the bars under expand”Under expand the game is expected to render into the extra canvas area
around the virtual rect. Two getters describe that space:
renderer.visibleCanvasRect— full canvas extent in virtual-space pixels. Extends pastvirtualSizeon the bar axis (negativex/y, dimensions larger than the virtual rect) whenever aspect mismatches. Iterate gridlines or backdrops against this rect so they cover every on-screen pixel, not just the play area.renderer.extendedVirtualRects— 0–2 rectangles of the visible canvas that sit outside virtual, in virtual-space pixels. Exactly the bars. Empty on aspect-matched hosts, undercover, and understretch.
import { Transform } from "@yagejs/core";import { GraphicsComponent, RendererKey } from "@yagejs/renderer";
const renderer = this.use(RendererKey);const bgGraphics = this.spawn("backdrop").add(new GraphicsComponent()).graphics;const fogGraphics = this.spawn("fog").add(new GraphicsComponent()).graphics;const hud = this.spawn("hud").add(new Transform());
// Backdrop that fills the whole canvas, extending into bars under expand:const canvas = renderer.visibleCanvasRect;bgGraphics.rect(canvas.x, canvas.y, canvas.width, canvas.height) .fill({ color: 0x0f172a });
// Fog-of-war over the bars:for (const bar of renderer.extendedVirtualRects) { fogGraphics.rect(bar.x, bar.y, bar.width, bar.height) .fill({ color: 0x000000, alpha: 0.78 });}
// HUD that follows the canvas corners (so cards live in the bars):const cornerTL = renderer.visibleCanvasRect;hud.setPosition(cornerTL.x + 16, cornerTL.y + 16);extendedVirtualRects is populated under letterbox too, geometrically
identical to expand, but under that mode it only reports where the bars
are. letterbox clips every scene layer to the virtual rect, so anything
drawn at those coordinates on a scene layer is invisible.
To put a scoreboard or branding in letterbox bars, parent a container directly on the Pixi stage, above the fit transform, and position it in canvas pixels:
import { Container, Graphics } from "pixi.js";import { RendererKey } from "@yagejs/renderer";
const renderer = this.use(RendererKey);const bars = new Container();renderer.application.stage.addChild(bars);
const { width } = renderer.canvasSize; // CSS pxconst play = renderer.virtualCanvasRect; // play area, in canvas px
const branding = new Graphics();branding.rect(0, 0, width, play.y).fill({ color: 0x0f172a });bars.addChild(branding);Reasoning about the cropped region under cover
Section titled “Reasoning about the cropped region under cover”renderer.croppedVirtualRects returns the complement of
visibleVirtualRect inside virtualSize — the 0–2 strips of virtual
space that are off-screen. Empty under letterbox / expand / stretch;
under cover it’s the top+bottom or left+right crop strips.
Use it when an effect needs to know “what’s beyond the player’s view”
specifically under cover: fog-of-war overlays that fade at the crop
boundary, indicators that pulse when off-screen enemies are nearby,
auto-panning cameras.
Positioning DOM overlays
Section titled “Positioning DOM overlays”renderer.virtualCanvasRect tells you where the play area lives on the
canvas in CSS pixels — useful for absolutely-positioned HTML overlays
(menus, tooltips, inspector panels) that should track the virtual rect
rather than the canvas:
import { RendererKey } from "@yagejs/renderer";
const renderer = this.use(RendererKey);const menuEl = document.getElementById("menu")!;
const r = renderer.virtualCanvasRect;menuEl.style.left = `${r.x}px`;menuEl.style.top = `${r.y}px`;menuEl.style.width = `${r.width}px`;menuEl.style.height = `${r.height}px`;Pair with virtualToCanvas(x, y) for single-point DOM mapping.
The built-in responsive-ui example demonstrates expand: the grid
extends across the whole canvas, fog covers the bars, and HUD cards
anchor to visibleCanvasRect corners — landing in the bars whenever
aspect mismatches.
Note on terminology: “screen” elsewhere in the engine (UI LayerSpace: "screen", Camera.screenToWorld) means virtual viewport space, not DOM
pixels. The canvasToVirtual method is named after its actual inputs (CSS
pixels relative to the canvas top-left) to avoid that collision.
When you use @yagejs/input alongside fit, pointer events and
coordinates wire up automatically. RendererPlugin registers itself
under RendererAdapterKey (from @yagejs/core), and InputPlugin
resolves that key during install — so pointer events target the canvas
and coordinates route through canvasToVirtual with no config. Just
make sure you register RendererPlugin before InputPlugin.
import { RendererPlugin } from "@yagejs/renderer";import { InputPlugin } from "@yagejs/input";
const host = document.getElementById("game")!;
engine.use(new RendererPlugin({ width: 800, height: 600, container: host }));engine.use(new InputPlugin({ actions: { /* ... */ } }));Fullscreen
Section titled “Fullscreen”RendererPlugin exposes a small fullscreen helper that wraps the
browser API with the legacy webkitRequestFullscreen fallback for
iOS Safari. The target is the configured container when present
(so DOM overlays alongside the canvas remain inside the fullscreen
area), or the canvas itself otherwise.
import { RendererPlugin } from "@yagejs/renderer";
const host = document.getElementById("game")!;const button = document.getElementById("fullscreen")!;
const renderer = new RendererPlugin({ width: 800, height: 600, container: host });engine.use(renderer);
button.addEventListener("click", () => { const toggle = renderer.isFullscreen ? renderer.exitFullscreen() : renderer.requestFullscreen(); toggle.catch((err: unknown) => console.warn(err)); // rejects if unsupported});| Member | Purpose |
|---|---|
requestFullscreen(): Promise<void> | Enter fullscreen on the host element. Rejects if unsupported. |
exitFullscreen(): Promise<void> | Exit fullscreen. No-op if not currently fullscreen. |
isFullscreen: boolean | Reads document.fullscreenElement live, so it stays correct when the user exits via Esc or browser UI. |
The renderer also emits a screen:fullscreen event on the engine
event bus whenever the fullscreen state changes (entering, exiting,
or the user pressing Esc). Subscribe via EventBusKey for HUD
relayouts or to update a button label:
import { EventBusKey } from "@yagejs/core";
const fullscreenButton = document.getElementById("fullscreen")!;
const bus = engine.context.resolve(EventBusKey);bus.on("screen:fullscreen", ({ active }) => { fullscreenButton.textContent = active ? "Exit fullscreen" : "Enter fullscreen";});iOS Safari caveat. requestFullscreen must be called from a
user-gesture handler (click, touch, key); the returned Promise rejects
with a TypeError otherwise, so wrap the call in try / catch (or
.catch()) if you want to recover gracefully. The
webkitRequestFullscreen variant on older iOS returns void rather
than a Promise; the wrapper handles both.
Orientation
Section titled “Orientation”The engine emits a screen:orientation event on the event bus when
the device orientation changes. Payload is
{ type: OrientationType } where OrientationType is the built-in
DOM union ("portrait-primary" | "portrait-secondary" | "landscape-primary" | "landscape-secondary").
import { EventBusKey } from "@yagejs/core";
const rotateOverlay = document.getElementById("rotate-device")!;const showRotateOverlay = () => (rotateOverlay.hidden = false);const hideRotateOverlay = () => (rotateOverlay.hidden = true);
const bus = engine.context.resolve(EventBusKey);bus.on("screen:orientation", ({ type }) => { if (type.startsWith("portrait")) showRotateOverlay(); else hideRotateOverlay();});For one-shot reads, renderer.orientation returns the current
orientation type, or null when neither the modern
screen.orientation API nor the legacy window.orientation angle
is available.
The renderer prefers window.screen.orientation.addEventListener ("change", …) and falls back to window.addEventListener ("orientationchange", …) on browsers that don’t expose the modern
API (older iOS Safari). Both wire onto the same bus event.
Mobile readiness
Section titled “Mobile readiness”On a phone, the browser takes space from your game: the address and tool
bars, the notch, the rounded corners, and the home indicator. The setup in
this section gives the game all of the space the phone leaves it. Both
create-yage templates already have the page setup. The Recommended template
also has a fullscreen button and can be installed to the Home Screen.
Page setup
Section titled “Page setup”Let the page cover the whole screen with viewport-fit=cover in the viewport
tag:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>Without it, iOS keeps the page inside a rectangle that avoids the notch and the rounded corners, and paints the page background around it.
Then size the game’s container to the visible screen and keep it clear of the notch and the home indicator:
#game { box-sizing: border-box; background: #0f172a; /* the renderer's backgroundColor */ width: 100vw; height: 100vh; /* for browsers without dvh */ height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);}- Use
100dvh, not100vh. On a phone,100vhis the height with the browser’s toolbars hidden. While the toolbars are showing, the bottom of a100vhgame sits behind them.100dvhis the height the player can see. - Don’t rely on scrolling to hide the toolbars. Mobile Safari does not hide them reliably, and a game page does not scroll.
- The safe-area padding keeps the game clear of the notch, the rounded
corners, and the home indicator. Each
env(safe-area-inset-*)value is the size of what to avoid on that side, and0where there is nothing to avoid. The values are only non-zero withviewport-fit=cover.RendererPluginsizes the canvas to the area inside the container’s padding, so no renderer config is needed. - Give the container a background. The padding shows the container’s
own background. In fullscreen the browser draws only the container, so a
background set on
bodyalone leaves black strips. Use the renderer’sbackgroundColor.
Fullscreen button
Section titled “Fullscreen button”A fullscreen button gives the game the whole screen where the browser allows it:
-
Put the button inside the renderer’s
container. The renderer makes the container fullscreen, so a button inside it stays on screen to exit. -
Hide the button when fullscreen is unavailable. iPhone cannot show a page fullscreen, and neither can an iframe without
allow="fullscreen". Checkdocument.fullscreenEnabledbefore showing the button:const button = document.getElementById("fullscreen")!;const doc = document as Document & { webkitFullscreenEnabled?: boolean };button.hidden = !(doc.fullscreenEnabled === true || doc.webkitFullscreenEnabled === true);iPad Safari before 16.4 only has
webkitFullscreenEnabled. -
Hide the button in an installed app that opens fullscreen. The game already fills the screen there. Check
matchMedia("(display-mode: fullscreen)").matches. -
Move focus off the button after a click. Otherwise the button keeps focus, and a game key such as Enter presses it again. Call
button.blur()in the click handler. -
iPad Safari adds its own controls. In fullscreen it shows an exit button over the page that the page cannot hide, and a swipe down exits fullscreen.
The Recommended template’s src/fullscreen.ts is a complete button that
follows these rules and changes its label on screen:fullscreen.
Rotate-device overlay
Section titled “Rotate-device overlay”Safari cannot lock the screen orientation: it has no
screen.orientation.lock(). Chrome on Android can lock it, but only while
the game is fullscreen or installed. For a landscape game, show an overlay
that asks the player to turn the device while the orientation is portrait.
Update the overlay on the screen:orientation event (see
Orientation). The event only fires on a change, so also check
renderer.orientation once at startup.
Add to Home Screen
Section titled “Add to Home Screen”On iPhone, the only way to remove Safari’s toolbars is for the player to add the game to the Home Screen and open it from there. It then opens without browser toolbars.
- iOS has no install prompt. Players use Share → Add to Home Screen in Safari. To help them, show your own instructions.
- Before iOS 26, the page needs a web app manifest. Since iOS 26, every
site added to the Home Screen opens without toolbars. Earlier versions only
do so when the manifest sets
displayto"standalone". iOS does not support"fullscreen". To also hide Android’s status and navigation bars, adddisplay_override: ["fullscreen"], which iOS ignores. <link rel="apple-touch-icon" href="/apple-touch-icon.png">sets the Home Screen icon, 180×180 pixels.<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">lets the installed game draw under the status bar. The safe-area padding keeps the game clear of the clock and battery.
The Recommended template’s production build has this manifest and these
tags. See
Installable and offline.
To make another project installable, copy the Recommended template’s
VitePWA entry in vite.config.ts, its icons in public/, and the icon and
status-bar tags in its index.html.
Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/packages/renderer.md.