Guide

GuideGetting StartedResize, DPR & the canvas

Resize, DPR & the canvas

Fit the canvas to the window, render crisply on high-DPI displays, and re-lay-out content when the size changes.

Intro~6 min read

What you'll learn

  • fit the canvas to its container with app.resize
  • render crisply on high-DPI displays with pixelRatio
  • re-lay-out content when the size changes

Before you start

Resize, DPR & the canvas

Your first scene rendered into a fixed 800×600 canvas. Real apps run in a window the player can resize, on a phone in portrait or landscape, and on high-DPI (“Retina”) displays where one CSS pixel covers several physical pixels. This chapter covers the three things that turn a fixed demo into something that fills its container and stays sharp.

Fixed vs. responsive

By default the Application uses the canvas.width and canvas.height you pass — a fixed-size surface. That is the right choice for a pixel-art game with a locked resolution, or any layout designed around exact dimensions.

For everything else you want the canvas to track its container. The size you draw against then changes at runtime, so any layout that assumes a fixed center or fixed edges needs to recompute when the size changes.

Crisp rendering with pixelRatio

On a high-DPI display, a canvas sized in CSS pixels is stretched across more physical pixels, so anything drawn at the CSS resolution looks soft. The pixelRatio canvas option scales the canvas backing store up by that factor while the CSS display size stays the same. It defaults to the display’s devicePixelRatio (clamped to 2), so output is crisp out of the box; set it explicitly to override:

Application with pixelRatio
const app = new Application({
  scenes: { ResizeScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
    pixelRatio: window.devicePixelRatio || 1,
  },
  clearColor: Color.black,
  loader: {
    basePath: 'assets/',
  },
});

With pixelRatio: 2, an 800×600 canvas renders into a 1600×1200 backing store but still displays at 800×600 CSS pixels — twice the detail per pixel, so sprites and text stay crisp. Pass pixelRatio: 1 to force a backing store that matches the CSS size.

Fitting the window

app.resize(width, height) sets a new logical size: it resizes the canvas (applying pixelRatio to the backing store), resizes the active backend’s render target, and dispatches the onResize signal. Call it once on startup and again whenever the window changes:

Resize to the window
// This example demonstrates manual resize handling: the canvas is resized to
// fill the window on every `resize` event. (For a hands-off alternative, pass a
// `ResponsiveCanvasSizing` as the `canvas.sizing` option and let it track the
// parent element instead.)
window.addEventListener('resize', () => {
  app.resize(window.innerWidth, window.innerHeight);
});

app.resize(window.innerWidth, window.innerHeight);

The first call after adding the listener sets the initial size; the listener keeps it in sync as the window changes. The width and height you pass are logical (CSS) pixels — pixelRatio is applied internally, so you never multiply by it yourself.

Re-laying out on size change

Resizing the surface does not move what you already positioned. A sprite centered at (400, 300) for an 800×600 canvas is no longer centered once the canvas grows. Recompute any size-dependent positions whenever the size changes — the example does this from a small layout helper:

Re-center content against the current size
private layout(): void {
  const app = this.app;
  const { width, height } = app;
  const dpr = Math.max(1, window.devicePixelRatio || 1);

  this.sprite.setPosition(width / 2, height / 2);
  this.info.setPosition(width / 2, 12);
  this.info.text = `${width}x${height} @ DPR ${dpr.toFixed(2)}`;
}

The example calls layout() every frame from update, which is simple and always correct. For heavier layouts, subscribe to app.onResize instead and recompute only when the size actually changes:

examples/guides/resize-dpr-and-canvas/custom-sizing.ts
override init(): void {
  this.layout();
  this.app.onResize.add(() => this.layout());
}

Either approach works — poll in update for small scenes, react to onResize when re-layout is expensive.

Sizing policies

The manual app.resize + onResize pattern above works well when you compute the size yourself. For the common cases — scaling a fixed-resolution canvas to its container, letting the render resolution follow the display, or taking over the container entirely — pass a canvas.sizing policy instead and let it manage the CSS and its own ResizeObserver for you:

import { Application, CappedResolutionCanvasSizing } from '@codexo/exojs';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizing: new CappedResolutionCanvasSizing(),
    },
});

canvas.width and canvas.height are the base resolution: the resolution you author against, the aspect ratio the policies preserve, and the cap they measure their render resolution against. Three things are derived from it, and they are deliberately separate:

  • the CSS size — how large the canvas appears on the page;
  • the logical size — app.width / app.height, the coordinates you position nodes in;
  • the backing store — app.canvas.width / app.canvas.height, the pixels the GPU actually draws.
Sizing CSS size Logical view Backing store Typical use
(omitted) base, fixed base, fixed base × DPR A canvas of an exact size, on a page that lays out around it.
FixedResolutionCanvasSizing scales to fit the parent base, fixed base × DPR Retro / pixel-art rendering, or any constant per-frame GPU cost.
CappedResolutionCanvasSizing scales to fit the parent base, fixed follows the display down to smaller, never above base × DPR The balanced responsive default.
ResponsiveCanvasSizing the whole parent adapts to the parent’s aspect display size × DPR Full-bleed layouts that should show more world, not bars.
ManualCanvasSizing left to the page set through app.resize set through app.resize A framework-managed layout or a worker-hosted surface.

Omitting sizing is the fixed case, so there is no class to pass for it and no observer is created. The three document-based policies each own exactly one ResizeObserver on the canvas’s parent element, so they need the canvas to be mounted — pass canvas.mount, or append your own element before constructing the Application.

Responsive views and minAspect

ResponsiveCanvasSizing is the only built-in whose logical view changes shape. Rather than letterboxing a fixed view inside the parent, it keeps the base view visible and opens up the axis the host has spare. With a base of 1280×720:

  • a 16:9 host renders 1280×720;
  • a 21:9 host renders 1680×720 — the same height, more world left and right;
  • a 4:3 host renders 1280×960 — the same width, more world above and below.

Nothing is stretched and nothing is cropped, but on a tall phone screen “more world above and below” can become a very tall view. minAspect sets how far the view may narrow horizontally before the extra space goes vertical instead:

import { Application, ResponsiveCanvasSizing } from '@codexo/exojs';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizing: new ResponsiveCanvasSizing({ minAspect: 1 }),
    },
});

With minAspect: 1 the view narrows down to a square (720×720) as the host gets narrower, and only below that does it start growing vertically. The default is the base aspect ratio, which never crops anything.

Switching at runtime

app.sizing is a live property, not just a constructor option. Assigning detaches the previous policy — releasing its observer and clearing the CSS box it wrote — and attaches the new one:

import { Application, ResponsiveCanvasSizing } from '@codexo/exojs';

const app = new Application({
    canvas: { width: 1280, height: 720, mount: document.body },
});

document.addEventListener('fullscreenchange', () => {
    app.sizing = document.fullscreenElement ? new ResponsiveCanvasSizing() : null;
});

Writing your own

CanvasSizing is a public base class, so a layout that none of the built-ins describe — an editor panel, a safe-area inset, a web component, a breakpoint-driven view — is a policy of your own:

examples/guides/resize-dpr-and-canvas/custom-sizing.ts
class HalfHeightCanvasSizing extends CanvasSizing {
  private observer: ResizeObserver | null = null;

  override attach(context: CanvasSizingContext): void {
    const host = context.host;

    if (host === null) return;

    const commit = (): void => {
      const width = host.clientWidth;
      const height = host.clientHeight / 2;

      context.apply({
        cssWidth: width,
        cssHeight: height,
        logicalWidth: context.baseWidth,
        logicalHeight: context.baseHeight,
        renderWidth: width,
        renderHeight: height,
      });
    };

    commit();
    this.observer = new ResizeObserver(commit);
    this.observer.observe(host);
  }

  override detach(): void {
    this.observer?.disconnect();
    this.observer = null;
  }
}

context.apply() is the only channel into the application: it resizes the backing store (renderWidth × pixelRatio), writes the CSS box, moves the logical coordinate system and dispatches onResize. detach() has to release everything attach() created — the application calls it when the policy is replaced and when it is destroyed.

Offscreen capture is unaffected by any of this: context.capture(node, { width, height }) renders into a RenderTexture of exactly the size you ask for, whatever the canvas is currently doing.

Example

Resize and DPROpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite, Text } from '@codexo/exojs';

class ResizeScene extends Scene {
  private sprite!: Sprite;
  private info!: Text;

  override init(): void {
    this.sprite = new Sprite(this.loader.get('image/ship-a.png'));
    this.sprite.setAnchor(0.5);

    this.info = new Text('', { fillColor: Color.white, fontSize: 16 });
    this.info.setAnchor(0.5, 0);

    this.layout();
  }

  override update(): void {
    this.layout();
  }

  override draw(context: RenderingContext): void {
    context.render(this.sprite);
    context.render(this.info);
  }

  private layout(): void {
    const app = this.app;
    const { width, height } = app;
    const dpr = Math.max(1, window.devicePixelRatio || 1);

    this.sprite.setPosition(width / 2, height / 2);
    this.info.setPosition(width / 2, 12);
    this.info.text = `${width}x${height} @ DPR ${dpr.toFixed(2)}`;
  }
}

const app = new Application({
  scenes: { ResizeScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
    pixelRatio: window.devicePixelRatio || 1,
  },
  clearColor: Color.black,
  loader: {
    basePath: 'assets/',
  },
});
document.body.style.margin = '0';

// This example demonstrates manual resize handling: the canvas is resized to
// fill the window on every `resize` event. (For a hands-off alternative, pass a
// `ResponsiveCanvasSizing` as the `canvas.sizing` option and let it track the
// parent element instead.)
window.addEventListener('resize', () => {
  app.resize(window.innerWidth, window.innerHeight);
});

app.resize(window.innerWidth, window.innerHeight);
await app.start(ResizeScene);

Drag the window edges: the canvas fills the viewport, the sprite stays centered, and the overlay reports the live canvas size and device pixel ratio.

Where to go next

The next part, Runtime, goes deeper on the Application — how it owns the canvas, the sizing you just used, and the frame loop — then moves on to scenes, the scene graph, and views.