# Chapter 6: Cameras and viewports (/guides/handmade-web-games/cameras-and-viewports)







## Screen space and world space [#screen-space-and-world-space]

So far whenever we've called a function like `fillRect(x, y, width, height)`, those `x`/`y` coordinates have been in <Screen>screen space</Screen>, where `x` starts at `0` from the left and `y` starts at `0` from the top of the canvas.

<Callout type="info" title="Wait, shouldn't that be ‘canvas space’ instead of ‘screen space’?">
  Arguably, yes! But in keeping with how it's often used in game engine parlance, "screen space"
  usually does not mean the whole monitor. Instead it commonly refers to the coordinates of the
  render target, which might be the whole monitor for a fullscreen game, or in our case, just the
  DPI-scaled canvas element. So wherever we say "screen space" in this chapter, know that we're
  really working in "canvas space".
</Callout>

Working directly in screen-space coordinates means that depending on your canvas size, more or less of the game world will be visible at any moment. This creates issues with fairness and consistency, and can be totally game-breaking. Imagine not being able to see half your board because you're on a small screen!

<Callout type="idea">
  These demos are resizable! Drag the resize handles to see how they compare.
</Callout>

<ResizableChess />

<Callout type="error" title="Using screen-space pixel coordinates">
  ```ts twoslash
  const ctx: CanvasRenderingContext2D = null as any as CanvasRenderingContext2D;
  // ---cut---
  for (let row = 0; row < 8; row++) {
    for (let col = 0; col < 8; col++) {
      const size = 40; // every tile is 40px x 40px
      const x = col * size; // and every coordinate needs to know this size
      const y = row * size;
      ctx.fillStyle = (row + col) % 2 === 0 ? "white" : "black";
      ctx.fillRect(x, y, size, size);
    }
  }
  ```
</Callout>

To make our chess game work consistently on all canvas sizes, we'll make a couple of changes:

1. Define an 8x8 grid for our <World>world space</World>.
2. Scale the canvas so that the grid fills either the full vertical or horizontal space available, whichever threshold is met first.

<ResizableChess />

How? Like this:

<Callout type="success" title="Using world-space coordinates">
  ```ts twoslash
  function inChessWorldSpace(
    ctx: CanvasRenderingContext2D,
    bounds: DOMRect, // canvas' boundingClientRect
    draw: () => void,
  ) {
    const size = Math.min(bounds.width, bounds.height); // the board is square, so constrain it by the shorter side of the canvas
    const scale = size / 8; // we'll scale up by this factor since it's an 8x8 board
    ctx.save();
    ctx.scale(scale, scale);
    draw(); // anything in this callback is now scaled to "chessboard units"
    ctx.restore();
  }
  ```

  ```ts twoslash
  function inChessWorldSpace(
    ctx: CanvasRenderingContext2D,
    bounds: DOMRect, // canvas' boundingClientRect
    draw: () => void,
  ) {
    const size = Math.min(bounds.width, bounds.height); // the board is square, so constrain it by the shorter side of the canvas
    const scale = size / 8; // we'll scale up by this factor since it's an 8x8 board
    ctx.save();
    ctx.scale(scale, scale);
    draw(); // anything in this callback is now scaled to "chessboard units"
    ctx.restore();
  }
  const canvas: HTMLCanvasElement = null as any as HTMLCanvasElement;
  const ctx: CanvasRenderingContext2D = null as any as CanvasRenderingContext2D;
  // ---cut---
  inChessWorldSpace(ctx, canvas.getBoundingClientRect(), () => {
    // in here, x=4 y=2 means "file 4, rank 2" in chessboard world space.
    // we're no longer thinking in pixels!
    for (let row = 0; row < 8; row++) {
      for (let col = 0; col < 8; col++) {
        ctx.fillStyle = (row + col) % 2 === 0 ? "white" : "black";
        ctx.fillRect(col, row, 1, 1);
      }
    }
  });
  ```
</Callout>

Now we have two coordinate systems to draw into, and it's trivial to swap between them. Anything outside the `inChessWorldSpace` callback is in <Screen>screen space</Screen> and anything inside of the callback is in <World>world space</World>, a new coordinate system we've created specifically for our game to make it easy to lay things out.

Let's also center the board and add some margins.

<ResizableChess />

```ts
function inChessWorldSpace(
  ctx: CanvasRenderingContext2D,
  width: number,
  height: number,
  draw: () => void,
) {
  const margin = 16; // [!code ++]
  const size = Math.min(width, height) - margin * 2; // [!code ++]
  const scale = size / 8;
  ctx.save();
  ctx.translate((width - size) / 2, (height - size) / 2); // [!code ++]
  ctx.scale(scale, scale);
  draw();
  ctx.restore();
}
```

A square `8 x 8` world is an extremely simple example, so let's apply this concept to another game, this time using a more standard `16:9` aspect ratio. We'll build pong within a `64 x 36` unit game world, giving our paddles and ball a width of `1`, but that choice of world size is totally arbitrary and we could have just as easily used `1920 x 1080` or any other `16:9` area.

<ResizablePong />

<Callout type="idea">
  Letterboxing a `16:9` game with optional margins is common enough to justify making a small helper function. This is the same math we used in `inChessWorldSpace` but applied now to two dimensions since our game world is no longer a square.

  ```ts twoslash
  function letterbox(
    container: { width: number; height: number },
    world: { width: number; height: number },
    margin = 0,
  ) {
    const scale = Math.min(
      (container.width - margin * 2) / world.width,
      (container.height - margin * 2) / world.height,
    );
    return {
      scale,
      xOffset: (container.width - world.width * scale) / 2,
      yOffset: (container.height - world.height * scale) / 2,
    };
  }
  ```
</Callout>

```ts twoslash
function letterbox(
  container: { width: number; height: number },
  world: { width: number; height: number },
  margin = 0,
) {
  const scale = Math.min(
    (container.width - margin * 2) / world.width,
    (container.height - margin * 2) / world.height,
  );
  return {
    scale,
    xOffset: (container.width - world.width * scale) / 2,
    yOffset: (container.height - world.height * scale) / 2,
  };
}
// ---cut---
function inWorldSpace(
  ctx: CanvasRenderingContext2D,
  gameArea: { width: number; height: number },
  canvasRect: { width: number; height: number },
  cb: () => void,
) {
  const margin = 16;
  const { scale, xOffset, yOffset } = letterbox(canvasRect, gameArea, margin);
  ctx.save();
  ctx.translate(xOffset, yOffset);
  ctx.scale(scale, scale);
  cb();
  ctx.restore();
}
```

All our game logic and draw code can now operate within a `64 x 36` <World>world space</World>, no matter what size the canvas element actually is.

```ts twoslash
const canvas: HTMLCanvasElement = null as any as HTMLCanvasElement;
const ctx: CanvasRenderingContext2D = null as any as CanvasRenderingContext2D;
function drawPaddle(ctx: CanvasRenderingContext2D, coords: { x: number; y: number }) {}
function drawBall(ctx: CanvasRenderingContext2D, coords: { x: number; y: number }) {}
function inWorldSpace(
  ctx: CanvasRenderingContext2D,
  gameArea: { width: number; height: number },
  canvasRect: { width: number; height: number },
  cb: () => void,
) {
  const margin = 16;
  const { scale, xOffset, yOffset } = letterbox(canvasRect, gameArea, margin);
  ctx.save();
  ctx.translate(xOffset, yOffset);
  ctx.scale(scale, scale);
  cb();
  ctx.restore();
}
function letterbox(
  container: { width: number; height: number },
  world: { width: number; height: number },
  margin = 0,
) {
  const scale = Math.min(
    (container.width - margin * 2) / world.width,
    (container.height - margin * 2) / world.height,
  );
  return {
    scale,
    xOffset: (container.width - world.width * scale) / 2,
    yOffset: (container.height - world.height * scale) / 2,
  };
}

// ---cut---
const bounds = canvas.getBoundingClientRect();
const gameArea = { width: 64, height: 36 };
const state = {
  ball: { x: 20, y: 14 },
  leftPaddle: { y: 10 },
  rightPaddle: { y: 20 },
};

function draw() {
  ctx.clearRect(0, 0, bounds.width, bounds.height);
  inWorldSpace(ctx, gameArea, bounds, () => {
    ctx.fillStyle = "#13131e";
    ctx.fillRect(0, 0, gameArea.width, gameArea.height);
    const paddleOffset = 4;
    const paddleWidth = 1;
    drawPaddle(ctx, {
      x: paddleOffset,
      y: state.leftPaddle.y,
    });
    drawPaddle(ctx, {
      x: gameArea.width - paddleOffset - paddleWidth,
      y: state.rightPaddle.y,
    });
    drawBall(ctx, state.ball);
  });
}
```

<Callout type="idea" title="Screen shake">
  With this world space coordinate conversion in place, look how easily we can add support for screen shake to our pong game. (We'll do a deep dive on this in [Chapter 10 →](/guides/handmade-web-games/techniques-for-game-feel-and-juice), this is just a sneak peek.)

  ```ts twoslash
  function letterbox(
    container: { width: number; height: number },
    world: { width: number; height: number },
    margin = 0,
  ) {
    const scale = Math.min(
      (container.width - margin * 2) / world.width,
      (container.height - margin * 2) / world.height,
    );
    return {
      scale,
      xOffset: (container.width - world.width * scale) / 2,
      yOffset: (container.height - world.height * scale) / 2,
    };
  }
  // ---cut---
  function inWorldSpace(
    ctx: CanvasRenderingContext2D,
    gameArea: { width: number; height: number },
    canvasRect: { width: number; height: number },
    shake: { x: number; y: number }, // [!code ++]
    cb: () => void,
  ) {
    const margin = 16;
    const { scale, xOffset, yOffset } = letterbox(canvasRect, gameArea, margin);
    ctx.save();
    ctx.translate(xOffset, yOffset);
    ctx.scale(scale, scale);
    ctx.translate(shake.x, shake.y); // [!code ++]
    cb();
    ctx.restore();
  }
  ```
</Callout>

## In-game cameras [#in-game-cameras]

Pong and chess both display the entire game world on screen at once, but what do we do when the game <World>world</World> is much larger than the portion we want to display? For that, we need an in-game <Camera>camera</Camera>.

<ResizableCarGame />

<small className="opacity-75 italic">
  Pixel art car created by [@MinZiin\_](https://minzinn.itch.io/)
</small>

<Callout type="idea">
  Try resizing the canvas and toggling the camera with the spacebar!
</Callout>

To start we'll create a state object for our camera.

```ts
const state = {
  camera: {
    x: 0,
    y: 0,
    zoom: 1,
  },
};
```

Then to use it...

```ts twoslash
function inCamera(
  ctx: CanvasRenderingContext2D,
  bounds: { width: number; height: number },
  camera: { x: number; y: number; zoom: number },
  draw: () => void,
) {
  ctx.save();
  // start at the center
  ctx.translate(bounds.width / 2, bounds.height / 2);
  // scale to the camera's zoom level
  ctx.scale(camera.zoom, camera.zoom);
  // translate to the camera's position
  ctx.translate(-camera.x, -camera.y);
  draw();
  ctx.restore();
}
```

```ts twoslash
function inWorldSpace(
  ctx: CanvasRenderingContext2D,
  gameArea: { width: number; height: number },
  canvasRect: { width: number; height: number },
  cb: () => void,
) {}
function inCamera(
  ctx: CanvasRenderingContext2D,
  bounds: { width: number; height: number },
  camera: { x: number; y: number; zoom: number },
  draw: () => void,
) {}
const state = {
  camera: {
    x: 320,
    y: 180,
    zoom: 3,
  },
  car: {
    x: 320,
    y: 240,
    angle: 0,
    speed: 0,
  },
};

type State = typeof state;

function drawTrack(ctx: CanvasRenderingContext2D) {}
function drawCar(ctx: CanvasRenderingContext2D, car: State["car"]) {}
function drawHud(ctx: CanvasRenderingContext2D, speed: number) {}
const ctx: CanvasRenderingContext2D = null as any as CanvasRenderingContext2D;
const gameArea = { width: 640, height: 360 };
const bounds: DOMRect = null as any as DOMRect;
// ---cut---
function draw() {
  inWorldSpace(ctx, gameArea, bounds, () => {
    inCamera(ctx, gameArea, state.camera, () => {
      drawTrack(ctx);
      drawCar(ctx, state.car);
    });
    drawHud(ctx, state.car.speed);
  });
}
```

Now we have two primitives to work with. `inWorldSpace` puts everything into game world units allowing us to draw in <World>world space</World>. `inCamera` then pans and zooms within our world space keeping our view within the <Camera>camera</Camera>. And anything drawn outside of those two functions will remain in <Screen>screen space</Screen>.

Right now there is a potential issue with our camera for some games. You can currently see more or less of the game world at different aspect ratios. (Try resizing the previous demo!) If you want to prevent players from seeing anything outside of the 16:9 letterboxed camera, using `.clip()` makes that possible.

<ResizableCarGame />

```ts twoslash
function inCamera(
  ctx: CanvasRenderingContext2D,
  bounds: { width: number; height: number },
  camera: { x: number; y: number; zoom: number },
  draw: () => void,
) {
  ctx.save();

  ctx.beginPath(); // [!code ++]
  ctx.rect(0, 0, bounds.width, bounds.height); // [!code ++]
  ctx.clip(); // [!code ++]

  ctx.translate(bounds.width / 2, bounds.height / 2);
  ctx.scale(camera.zoom, camera.zoom);
  ctx.translate(-camera.x, -camera.y);
  draw();
  ctx.restore();
}
```

## <Screen>Screen</Screen>, <World>world</World>, and <Camera>camera</Camera> [#screen-world-and-camera]

The main takeaway I'd like to leave you with is that the <Camera>camera</Camera> is really just a set of transforms. First, we can make our lives easier by setting our game units to something that makes sense for our specific game world (<World>world space</World>). And then we can pan and zoom around that world with basic transforms in our <Camera>camera</Camera>. Everything outside of these callbacks will continue to be in <Screen>screen space</Screen>, which is made up of the DPI-scaled pixels within our `<canvas>`.

There are many ways to take this from here. A few ideas to get you thinking...

1. Instead of always snapping the camera right to the player, try using an exponential follow (see [Chapter 4](/guides/handmade-web-games/animation)) so that the camera lags slightly behind the player's position.
2. Offset the camera's `x` and `y` whenever there's an impact in your game to add screen shake.
3. Don't render things that are a certain distance outside of the camera.
4. Add rotations to the set of transforms done in-camera.
5. Think about how you'd convert a mouse event's `x`/`y` (`window`-space) coordinates into world-space or in-camera coordinates so you can let players click/hover/drag in-game items. (We'll answer this in [the next chapter →](/guides/handmade-web-games/mouse-and-keyboard-input).)

And just to recap, our final car/racetrack code now looks something like this:

```ts twoslash
export {};

/** Width and height in a single coordinate space */
type Size = {
  width: number;
  height: number;
};

/** Camera position and zoom within world space */
type Camera = {
  /** World-space x the camera is centered on */
  x: number;
  /** World-space y the camera is centered on */
  y: number;
  /** Zoom multiplier (1 = no zoom) */
  zoom: number;
};

/** Player car state in world space */
type Car = {
  x: number;
  y: number;
  angle: number;
  speed: number;
};

type State = {
  /** Pan/zoom view into the world */
  camera: Camera;
  car: Car;
};

/**
 * Scale and letterbox so drawing uses game-world units.
 * Anything drawn in `cb` is in world space.
 */
declare function inWorldSpace(
  ctx: CanvasRenderingContext2D,
  gameArea: Size,
  canvasRect: Size,
  cb: () => void,
): void;

/**
 * Pan and zoom within world space.
 * Anything drawn in `draw` is viewed through the camera.
 */
declare function inCamera(
  ctx: CanvasRenderingContext2D,
  bounds: Size,
  camera: Camera,
  draw: () => void,
): void;

declare function drawTrack(ctx: CanvasRenderingContext2D): void;
declare function drawCar(ctx: CanvasRenderingContext2D, car: Car): void;
declare function drawHud(ctx: CanvasRenderingContext2D, speed: number): void;
/** Dev-only overlay drawn in screen space (FPS, hitboxes, etc.) */
declare function drawDebugOverlay(ctx: CanvasRenderingContext2D, state: State): void;

declare global {
  interface ImportMeta {
    readonly env: {
      /** `true` during `vite`/`bun` development builds */
      readonly DEV: boolean;
    };
  }
}

declare const ctx: CanvasRenderingContext2D;
declare const gameArea: Size;
declare const bounds: DOMRect;
declare const state: State;
// ---cut---
function draw() {
  // 1. in screen space (raw canvas pixels)
  ctx.clearRect(0, 0, bounds.width, bounds.height);

  inWorldSpace(ctx, gameArea, bounds, () => {
    inCamera(ctx, gameArea, state.camera, () => {
      // 2. the camera-viewed world
      drawTrack(ctx);
      drawCar(ctx, state.car);
    });

    // 3. in world-space units w/ letterboxing, but floating outside of the camera
    drawHud(ctx, state.car.speed);
  });

  // 4. back in screen space (usually FPS/debug ui)
  if (import.meta.env.DEV) {
    drawDebugOverlay(ctx, state);
  }
}
```

## Up next [#up-next]

Our camera demo had the player moving around inside of the game world using key presses. We'll cover how to react to these inputs in [Chapter 7 →](/guides/handmade-web-games/mouse-and-keyboard-input) where we wire browser events into the game loop.
