Guide

GuideRuntimeCoordinates and views

Coordinates and views

World space, canvas space, and how the active view maps between them — plus camera movement, zoom, and split rendering.

Intermediate~5 min read

What you'll learn

  • map world space to screen space
  • move and zoom a camera view

Before you start

Coordinates and views

ExoJS keeps two coordinate systems in mind at once. Drawables are positioned in world space — the coordinate system you write game logic in. Input and view APIs use logical canvas space, also called design or screen space. A View sits between the two and handles the mapping.

Understanding this split makes camera follow, zoom, split-screen, and HUD overlays straightforward. Confusing them is the source of most “the position is wrong” bugs.

World space

When you call sprite.setPosition(400, 300), you’re setting a position in world space. The numbers you choose are the same numbers your game logic operates on — distances, velocities, hit boxes. They have nothing to do with the user’s screen.

In a typical 2D project, world space has the X axis pointing right and the Y axis pointing down (matching browser conventions). The origin is wherever you put it; many projects use (0, 0) as the world’s center, others put it at the top-left of the playable area.

Canvas space

Canvas space runs from (0, 0) at the top-left to (app.width, app.height) at the bottom-right. Pointer events arrive in these logical design pixels, independent of CSS size and device pixel ratio. The surface’s app.canvas.width and app.canvas.height are physical backing-store dimensions and are normally only relevant to low-level rendering code.

If you placed every sprite at its world position and the camera never moved, world space and canvas space would line up exactly. The moment you move the camera, scale the canvas, or render to a sub-region, the two diverge.

The active view

Every render backend exposes a view property. The view defines how world space maps onto the canvas:

  • view.center — the world-space point at the center of the canvas.
  • view.size — how much world space fits inside the canvas.
  • view.rotation — the camera’s rotation in degrees.
  • view.viewport — which sub-region of the canvas the view paints into (default: the whole canvas).

Set the center to (400, 300) and the size to (800, 600), and the canvas shows the world from (0, 0) to (800, 600). Move the center to (400, 600) and the camera scrolls down by 300 pixels’ worth of world.

examples/guides/coordinates-and-views/views.ts
override draw(context: RenderingContext): void {
  context.view.setCenter(400, 300);
  context.view.resize(800, 600);

  context.render(this.world);
}

You don’t usually configure the view by hand — it tracks the canvas size automatically. Reach for setCenter, move, setZoom, and setRotation when you need to script view behavior. Access the active view through the context passed to draw, using context.view.

Following an object

The view supports following a target. The target can be any object with x and y properties — typically a scene node, but a plain {x, y} object works too:

examples/guides/coordinates-and-views/views.ts
context.view.follow(this.player);

context.render(this.world);

Following keeps the view centered on the target while the scene renders. Call context.view.clearFollow() to detach. If you want smoothing, keep a small camera target object and move it toward the player in update, then follow that target instead of the player directly.

Zooming

The setZoom(z) method scales how much world fits inside the view. setZoom(2) means the view shows half as much world as it normally would (everything looks twice as big). setZoom(0.5) does the opposite (everything looks zoomed out). The zoomIn(amount) and zoomOut(amount) helpers adjust relative to the current zoom.

examples/guides/coordinates-and-views/views.ts
override init(): void {
  this.zoom = 1;

  this.inputs.onTrigger(Keyboard.Equal, () => {
    // the key printed =/+ on US QWERTY
    this.zoom += 0.1;
  });
}

override draw(context: RenderingContext): void {
  context.view.setZoom(this.zoom);

  context.render(this.world);
}

Common pitfalls

A handful of misalignments come up regularly:

  • Hardcoded canvas-relative positions. Setting a sprite to (400, 300) because that “looks centered for an 800×600 canvas” breaks the moment the canvas resizes. Use the active view’s center, the canvas size at runtime, or anchor-aware containers instead.
  • Anchor confusion. A sprite’s position is the world-space location of its anchor. The default anchor is the top-left, so setPosition(400, 300) puts the top-left of the sprite at (400, 300), not its middle. Call setAnchor(0.5) to pivot at the center.
  • Pointer events without view conversion. Pointer events arrive in logical canvas space. If your camera has moved, you need to convert them back to world space before testing whether the click hit a world-space object. The world-vs-screen-coords example below shows the conversion.
  • HUD inside the world. A health bar should not move when the camera moves. Add HUD elements to scene.ui; it stays in logical canvas space and renders above the world automatically.

Multiple views

The active view can be swapped on every frame. Used together with viewports, this gives you split-screen, picture-in-picture, and minimap rendering — render the world once with one view configuration into one viewport, then again with a different configuration into another:

examples/guides/coordinates-and-views/views.ts
override draw(context: RenderingContext): void {
  context.view.setViewport(0, 0, 0.5, 1);
  context.view.setCenter(this.player1.x, this.player1.y);
  context.render(this.world);

  context.view.setViewport(0.5, 0, 0.5, 1);
  context.view.setCenter(this.player2.x, this.player2.y);
  context.render(this.world);
}

The same scene graph rendered with two different view configurations, into two halves of the canvas — one tree, two views. The viewport fractions are normalized (0–1 of canvas size), so setViewport(0, 0, 0.5, 1) covers the left half and setViewport(0.5, 0, 0.5, 1) covers the right. If you continue rendering HUD elements or additional scenes afterward, reset the viewport before those passes so later rendering does not inherit the split-screen configuration.

Examples

Camera and ViewKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, type RenderingContext, Scene, type Seconds, Text, View } from '@codexo/exojs';

class CameraViewScene extends Scene {
  private camera!: View;
  private world!: Graphics;
  private overlay!: Text;
  private moveX = 0;
  private moveY = 0;
  private zoom = 0;

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    this.camera = new View(0, 0, width, height);

    this.world = new Graphics();
    this.world.lineWidth = 2;
    this.world.lineColor = new Color(0xa9a9a9);

    for (let x = -1200; x <= 1200; x += 120) {
      this.world.drawLine(x, -900, x, 900);
    }

    for (let y = -900; y <= 900; y += 120) {
      this.world.drawLine(-1200, y, 1200, y);
    }

    this.overlay = new Text('WASD pan, Q/E zoom', { fillColor: Color.white, fontSize: 16 });
    this.overlay.setPosition(12, 12);

    this.inputs.onActive(Keyboard.A, () => {
      this.moveX = -1;
    });
    this.inputs.onStop(Keyboard.A, () => {
      if (this.moveX < 0) this.moveX = 0;
    });
    this.inputs.onActive(Keyboard.D, () => {
      this.moveX = 1;
    });
    this.inputs.onStop(Keyboard.D, () => {
      if (this.moveX > 0) this.moveX = 0;
    });
    this.inputs.onActive(Keyboard.W, () => {
      this.moveY = -1;
    });
    this.inputs.onStop(Keyboard.W, () => {
      if (this.moveY < 0) this.moveY = 0;
    });
    this.inputs.onActive(Keyboard.S, () => {
      this.moveY = 1;
    });
    this.inputs.onStop(Keyboard.S, () => {
      if (this.moveY > 0) this.moveY = 0;
    });
    this.inputs.onActive(Keyboard.Q, () => {
      this.zoom = 1;
    });
    this.inputs.onStop(Keyboard.Q, () => {
      if (this.zoom > 0) this.zoom = 0;
    });
    this.inputs.onActive(Keyboard.E, () => {
      this.zoom = -1;
    });
    this.inputs.onStop(Keyboard.E, () => {
      if (this.zoom < 0) this.zoom = 0;
    });
  }

  override update(delta: Seconds): void {
    this.camera.move(this.moveX * 420 * delta, this.moveY * 420 * delta);
    this.camera.setZoom(Math.max(0.25, this.camera.zoomLevel + this.zoom * 0.75 * delta));
  }

  override draw(context: RenderingContext): void {
    context.render(this.world, { view: this.camera });
    context.render(this.overlay, { view: context.screenView });
  }
}

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

await app.start(CameraViewScene);

Pan and zoom the view across a scene that’s larger than the canvas.

Multi View Split ScreenKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, type RenderingContext, Scene, type Seconds, Sprite, Texture, View } from '@codexo/exojs';

class SplitScreenScene extends Scene {
  private texture!: Texture;
  private leftView!: View;
  private rightView!: View;
  private divider!: Graphics;
  private leftPlayer!: Sprite;
  private rightPlayer!: Sprite;
  private move = {
    a: 0,
    d: 0,
    w: 0,
    s: 0,
    left: 0,
    right: 0,
    up: 0,
    down: 0,
  };

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    this.texture = this.loader.get('image/ship-a.png');

    this.leftView = new View(0, 0, width / 2, height).setViewport(0, 0, 0.5, 1);
    this.rightView = new View(0, 0, width / 2, height).setViewport(0.5, 0, 0.5, 1);

    this.divider = new Graphics();
    this.divider.fillColor = Color.white;
    this.divider.drawRectangle(width / 2 - 1, 0, 2, height);

    this.leftPlayer = new Sprite(this.texture)
      .setAnchor(0.5)
      .setPosition(-160, 0)
      .setTint(new Color(120, 190, 255));
    this.rightPlayer = new Sprite(this.texture)
      .setAnchor(0.5)
      .setPosition(160, 0)
      .setTint(new Color(255, 180, 120));

    this.inputs.onActive(Keyboard.A, () => {
      this.move.a = 1;
    });
    this.inputs.onStop(Keyboard.A, () => {
      this.move.a = 0;
    });
    this.inputs.onActive(Keyboard.D, () => {
      this.move.d = 1;
    });
    this.inputs.onStop(Keyboard.D, () => {
      this.move.d = 0;
    });
    this.inputs.onActive(Keyboard.W, () => {
      this.move.w = 1;
    });
    this.inputs.onStop(Keyboard.W, () => {
      this.move.w = 0;
    });
    this.inputs.onActive(Keyboard.S, () => {
      this.move.s = 1;
    });
    this.inputs.onStop(Keyboard.S, () => {
      this.move.s = 0;
    });
    this.inputs.onActive(Keyboard.Left, () => {
      this.move.left = 1;
    });
    this.inputs.onStop(Keyboard.Left, () => {
      this.move.left = 0;
    });
    this.inputs.onActive(Keyboard.Right, () => {
      this.move.right = 1;
    });
    this.inputs.onStop(Keyboard.Right, () => {
      this.move.right = 0;
    });
    this.inputs.onActive(Keyboard.Up, () => {
      this.move.up = 1;
    });
    this.inputs.onStop(Keyboard.Up, () => {
      this.move.up = 0;
    });
    this.inputs.onActive(Keyboard.Down, () => {
      this.move.down = 1;
    });
    this.inputs.onStop(Keyboard.Down, () => {
      this.move.down = 0;
    });
  }

  override update(delta: Seconds): void {
    const speed = 300 * delta;

    this.leftPlayer.move((this.move.d - this.move.a) * speed, (this.move.s - this.move.w) * speed);
    this.rightPlayer.move((this.move.right - this.move.left) * speed, (this.move.down - this.move.up) * speed);
    this.leftView.setCenter(this.leftPlayer.position.x, this.leftPlayer.position.y);
    this.rightView.setCenter(this.rightPlayer.position.x, this.rightPlayer.position.y);
  }

  override draw(context: RenderingContext): void {
    context.render(this.leftPlayer, { view: this.leftView });
    context.render(this.rightPlayer, { view: this.leftView });
    context.render(this.leftPlayer, { view: this.rightView });
    context.render(this.rightPlayer, { view: this.rightView });
    context.render(this.divider, { view: context.screenView });
  }
}

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

await app.start(SplitScreenScene);

Two players, one world, two viewports.

Picture in PictureOpen in PlaygroundView source

Preview is paused until you click Play.

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

class PictureInPictureScene extends Scene {
  private mainView!: View;
  private pipView!: View;
  private sprite!: Sprite;
  private velocity = 220;
  private frame!: Graphics;

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    this.sprite = new Sprite(this.loader.get('image/ship-a.png'));

    this.mainView = new View(0, 0, width, height);
    this.pipView = new View(0, 0, width * 0.3, height * 0.3).setViewport(0.68, 0.04, 0.28, 0.28);
    // Zoom < 1 zooms OUT (a larger visible world area maps into the same
    // small viewport) - a minimap needs to show more of the scene than the
    // main view, not less, so the tracked sprite reads as a small icon.
    this.pipView.setZoom(0.4);

    this.sprite.setAnchor(0.5).setPosition(-280, 0);

    this.frame = new Graphics();
    this.frame.lineWidth = 3;
    this.frame.lineColor = Color.white;
    this.frame.drawRectangle(width * 0.68, height * 0.04, width * 0.28, height * 0.28);
  }

  override update(delta: Seconds): void {
    this.sprite.move(this.velocity * delta, 0);

    if (this.sprite.position.x > 320 || this.sprite.position.x < -320) {
      this.velocity *= -1;
    }

    this.pipView.follow(this.sprite, { lerp: 1 });
  }

  override draw(context: RenderingContext): void {
    context.render(this.sprite, { view: this.mainView });
    context.render(this.sprite, { view: this.pipView });
    context.render(this.frame, { view: context.screenView });
  }
}

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

await app.start(PictureInPictureScene);

A second view rendered into a small region of the canvas — useful for minimaps and replay overlays.

World Vs Screen CoordsPointerOpen in PlaygroundView source

Preview is paused until you click Play.

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

class WorldScreenScene extends Scene {
  private view!: View;
  private grid!: Graphics;
  private markers!: Graphics;
  private text!: Text;
  private pointer = { x: 0, y: 0 };

  override init(): void {
    const app = this.app;
    const width = app.width;
    const height = app.height;

    this.view = new View(260, 160, width, height);
    this.grid = new Graphics();
    this.markers = new Graphics();
    this.text = new Text('', { fillColor: Color.white, fontSize: 16 });

    this.grid.lineWidth = 1;
    this.grid.lineColor = new Color(60, 60, 60);

    for (let x = -200; x <= 1200; x += 100) {
      this.grid.drawLine(x, -200, x, 1000);
    }

    for (let y = -200; y <= 1000; y += 100) {
      this.grid.drawLine(-200, y, 1200, y);
    }

    app.input.onPointerMove.add(pointer => {
      this.pointer = { x: pointer.x, y: pointer.y };
    });

    app.input.onPointerTap.add(pointer => {
      const world = this.toWorld(pointer.x, pointer.y);
      this.markers.fillColor = new Color(255, 220, 80);
      this.markers.drawCircle(world.x, world.y, 8);
    });
  }

  override draw(context: RenderingContext): void {
    const world = this.toWorld(this.pointer.x, this.pointer.y);

    this.text.text = `screen: ${this.pointer.x | 0}, ${this.pointer.y | 0}\nworld: ${world.x | 0}, ${world.y | 0}`;
    this.text.setPosition(12, 12);

    context.backend.setView(this.view);
    context.render(this.grid);
    context.render(this.markers);
    context.backend.setView(null);
    context.render(this.text);
  }

  private toWorld(screenX: number, screenY: number): { x: number; y: number } {
    // Pointer coordinates are already in design space, so screenToWorld only
    // has to undo this view's camera transform (pan/zoom) to reach world space.
    return this.view.screenToWorld(screenX, screenY);
  }
}

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

await app.start(WorldScreenScene);

Click anywhere on the canvas; the example shows the same point in both coordinate systems and how to convert between them.

Where to go next

The next chapter, Loading and resources, covers the asset pipeline — how the loader gets textures, audio, and JSON into your scene before init runs.