Skip to content

Pixels, Coordinates and the Y-Axis

Two coordinate systems that disagree about which way is up, and the one line of code that moves between them. Why Y points down on a screen, what that does to every angle and every “up” you write, and the fix that is one minus sign in one place rather than a sign scattered through your maths. And why measuring a game in pixels quietly breaks it on a different screen.

This is the first Section because it is the single most common source of confusion in 2D, and it has no equivalent in 3D. Get it settled once and it stops costing you anything.

Draw a graph in a maths class and the origin goes in the bottom-left, with Y counting upward. Every piece of trigonometry, every formula, every intuition you have about “up” assumes that.

A canvas puts its origin in the top-left and counts Y downward. Not to be difficult — it is how screens have been drawn since television scanned lines from the top. Every 2D drawing API you will meet does the same.

So the same dot has two different addresses, and both are correct.

One point, read as world units and as canvas pixels at once
The code that draws it src/lib/gamedev/demos/2d/axes.scene.ts
/** One point, read as world units and as canvas pixels at the same time. */
import { makeCanvas2D, arrow, dot, label, line } from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addSlider, addCheckbox, addReadout } from "../controls.ts";
import { pixelsPerUnit, worldToScreen } from "../../../gamedev2d/screen.ts";
import { VIEW, WORLD_HEIGHT, bothReadings, pointFrom } from "./axes-shared.ts";
import type { MountFn } from "../runner.ts";

const GRID = "#252b33";
const SCREEN_AXIS = "#f0883e";
const WORLD_AXIS = "#39d3c3";
const POINT = "#d2a8ff";
const TEXT = "#9198a1";

const mount: MountFn = (el) => {
  const { ctx, width, height, clear } = makeCanvas2D(el, 340);

  const show = addReadout(el);
  const across = addSlider(el, "across", 0, 16, 3, draw, " units", 0.1);
  const second = addSlider(
    el,
    "the second coordinate",
    0,
    8.7,
    2,
    draw,
    "",
    0.1,
  );
  const canvasStyle = addCheckbox(
    el,
    "read the second coordinate the way a canvas does: down from the top",
    false,
    draw,
  );

  function draw() {
    clear();
    const scale = pixelsPerUnit(VIEW);

    // A unit grid, so "one unit" is a visible amount rather than an abstraction.
    for (let u = 0; u <= 16; u += 1) {
      const x = u * scale;
      line(ctx, { x, y: 0 }, { x, y: height }, GRID, { width: 1 });
    }
    for (let v = 0; v <= Math.ceil(WORLD_HEIGHT); v += 1) {
      const y = height - v * scale;
      line(ctx, { x: 0, y }, { x: width, y }, GRID, { width: 1 });
    }

    // The canvas's own axes: origin top-left, Y counting downward.
    arrow(ctx, { x: 0, y: 0 }, { x: 74, y: 0 }, SCREEN_AXIS);
    arrow(ctx, { x: 0, y: 0 }, { x: 0, y: 74 }, SCREEN_AXIS);
    label(ctx, "screen +x", 80, 12, SCREEN_AXIS);
    label(ctx, "screen +y, downward", 6, 88, SCREEN_AXIS);
    label(ctx, "(0, 0) for the canvas", 6, 100, TEXT);

    // The world's axes: origin bottom-left, Y counting upward.
    arrow(ctx, { x: 0, y: height }, { x: 74, y: height }, WORLD_AXIS);
    arrow(ctx, { x: 0, y: height }, { x: 0, y: height - 74 }, WORLD_AXIS);
    label(ctx, "world +x", 80, height - 6, WORLD_AXIS);
    label(ctx, "world +y, upward", 6, height - 84, WORLD_AXIS);
    label(ctx, "(0, 0) for the maths", 6, height - 96, TEXT);

    const p = pointFrom(across(), second(), canvasStyle());
    const both = bothReadings(p);
    const at = worldToScreen(p, VIEW);

    // Dashed guides to each origin, so both readings are visible as distances.
    line(ctx, { x: at.x, y: at.y }, { x: at.x, y: 0 }, SCREEN_AXIS, {
      dashed: true,
      width: 1,
    });
    line(ctx, { x: at.x, y: 0 }, { x: 0, y: 0 }, SCREEN_AXIS, {
      dashed: true,
      width: 1,
    });
    line(ctx, { x: at.x, y: at.y }, { x: at.x, y: height }, WORLD_AXIS, {
      dashed: true,
      width: 1,
    });
    line(ctx, { x: at.x, y: height }, { x: 0, y: height }, WORLD_AXIS, {
      dashed: true,
      width: 1,
    });

    dot(ctx, at.x, at.y, 6, POINT);
    label(
      ctx,
      `world (${both.world.x.toFixed(1)}, ${both.world.y.toFixed(1)})`,
      at.x + 12,
      at.y - 4,
      WORLD_AXIS,
    );
    label(
      ctx,
      `screen (${Math.round(both.screen.x)}, ${Math.round(both.screen.y)}) px`,
      at.x + 12,
      at.y + 10,
      SCREEN_AXIS,
    );

    show(
      canvasStyle()
        ? `the slider counts down from the top, so raising it moves the dot down \u00B7 ${scale} pixels to a unit`
        : `the slider counts up from the bottom, so raising it moves the dot up \u00B7 ${scale} pixels to a unit`,
    );
  }

  draw();

  return () => {};
};

export default mount;

The teal axes at the bottom-left are the world’s. The orange axes at the top-left are the canvas’s. The dot is in one place, and the two labels next to it are two ways of saying where.

Now tick the checkbox. The slider hasn’t changed and neither has its number — the dot moves to the other end. That is the whole problem, in one control: “y = 2” means two different places depending on who is speaking.

xscreen=xworldsyscreen=hyworldsx_{\text{screen}} = x_{\text{world}} \cdot s \qquad y_{\text{screen}} = h - y_{\text{world}} \cdot s

where ss is how many pixels one world unit is worth and hh is the canvas height in pixels. The xx line is just a scale. All of the difficulty is in that subtraction, and once it is written down once you never think about it again.

Going the other way — which every mouse click needs — undoes the two steps in reverse:

yworld=hyscreensy_{\text{world}} = \frac{h - y_{\text{screen}}}{s}

The build check sweeps a grid of 3,721 points through both directions and requires them to come back where they started, to better than 101210^{-12}. That matters more than it sounds: a flipped sign still draws a dot in a perfectly believable place. It is only wrong by being upside down, which is precisely the bug a picture cannot rule out.

Here is the consequence that catches everyone at least once:

on a canvas, up is (0,1)\text{on a canvas, up is } (0, -1)

To move something up the screen you subtract from Y. So a jump is y -= speed and gravity is y += speed — both of which read backwards to anyone who has done any physics, and both of which are correct if you are working in screen coordinates.

Which is the argument for not working in screen coordinates.

The flip does something less obvious and more annoying: it reverses the direction of rotation.

Trigonometry measures angles counter-clockwise from the positive X axis. That is what the unit circle means, and it is where cos\cos and sin\sin come from. Flip the Y axis and that same positive angle sweeps clockwise.

The same angle, turning one way in the maths and the other on the canvas
The code that draws it src/lib/gamedev/demos/2d/spin.scene.ts
/** The same positive angle, turning one way in the maths and the other way on the canvas. */
import { makeCanvas2D, arrow, dot, label } from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addSlider, addReadout } from "../controls.ts";
import { directionFromAngle } from "../../../gamedev2d/screen.ts";
import type { MountFn } from "../runner.ts";

const RIGHT = "#39d3c3";
const WRONG = "#ff7b72";
const DIM = "#484f58";
const TEXT = "#9198a1";

const mount: MountFn = (el) => {
  const { ctx, width, height, clear } = makeCanvas2D(el, 280);

  const show = addReadout(el);
  const angle = addSlider(el, "angle", 0, 360, 40, draw);

  function draw() {
    clear();
    const radians = (angle() * Math.PI) / 180;
    const reach = 78;
    const centres = [
      { x: width * 0.28, y: height * 0.52, colour: RIGHT },
      { x: width * 0.72, y: height * 0.52, colour: WRONG },
    ];

    // Left: converted properly. The world direction has its y negated at the drawing step only.
    const d = directionFromAngle(radians);
    arrow(
      ctx,
      centres[0],
      { x: centres[0].x + d.x * reach, y: centres[0].y - d.y * reach },
      RIGHT,
      2.4,
    );

    // Right: the same angle handed straight to the canvas, with no flip. It turns the other way.
    arrow(
      ctx,
      centres[1],
      { x: centres[1].x + d.x * reach, y: centres[1].y + d.y * reach },
      WRONG,
      2.4,
    );

    for (const c of centres) {
      // The +x axis each arrow is measured from, and the arc it has swept.
      arrow(ctx, c, { x: c.x + reach + 14, y: c.y }, DIM, 1.2);
      dot(ctx, c.x, c.y, 4, c.colour);
      ctx.save();
      ctx.strokeStyle = c.colour;
      ctx.lineWidth = 1.4;
      ctx.beginPath();
      const downward = c.colour === WRONG;
      ctx.arc(c.x, c.y, 34, 0, downward ? radians : -radians, !downward);
      ctx.stroke();
      ctx.restore();
    }

    label(
      ctx,
      "y negated when drawing",
      centres[0].x,
      height - 26,
      RIGHT,
      "center",
    );
    label(
      ctx,
      "counter-clockwise, as the unit circle says",
      centres[0].x,
      height - 12,
      TEXT,
      "center",
    );
    label(ctx, "angle used raw", centres[1].x, height - 26, WRONG, "center");
    label(
      ctx,
      "clockwise, because y grows downward",
      centres[1].x,
      height - 12,
      TEXT,
      "center",
    );

    show(
      `${angle()}\u00B0 \u00B7 the direction is (${d.x.toFixed(2)}, ${d.y.toFixed(2)}) in world units, ` +
        `and drawing it needs y flipped to (${d.x.toFixed(2)}, ${(-d.y).toFixed(2)})`,
    );
  }

  draw();

  return () => {};
};

export default mount;

Both arrows are being handed the identical angle. The teal one has its Y negated at the drawing step; the red one does not. Sweep the slider and they part company immediately.

θscreen=θworld\theta_{\text{screen}} = -\,\theta_{\text{world}}

This is why ctx.rotate(0.5) seems to turn the “wrong” way compared to the unit circle. It isn’t wrong — it is measuring in a system where Y grows downward, and being perfectly consistent about it.

The fix is the same fix. Compute the direction in world coordinates, where (cosθ,sinθ)(\cos\theta, \sin\theta) means what you expect, and negate the Y when you draw. The check verifies that a positive angle points up in the world and lands higher on the canvas once converted, which is the pair of facts that has to hold together.

The second half of this Section, and the half that shows up later as “the game plays differently on my laptop”.

Say a character moves 5 pixels a step. On a 320-pixel-wide canvas that is a substantial stride. On a 1920-pixel-wide one it is a twitch. Same code, same number, different game.

One world point on three canvases
The code src/lib/gamedev/demos/2d/resolution.ts
/** The same world point on three canvases: different pixels, identical fractions. */
import {
  fractionOf,
  pixelsInUnits,
  pixelsPerUnit,
  worldToScreen,
  type View,
} from "../../../gamedev2d/screen.ts";
import type { Demo } from "../runner.ts";

/** Three sizes, all exactly 16:9, so only the resolution differs. */
const SIZES: View[] = [
  { pixelWidth: 320, pixelHeight: 180, unitsAcross: 16 },
  { pixelWidth: 960, pixelHeight: 540, unitsAcross: 16 },
  { pixelWidth: 1920, pixelHeight: 1080, unitsAcross: 16 },
];
const PLAYER = { x: 3, y: 2 };

const demo: Demo = (log) => {
  for (const view of SIZES) {
    const at = worldToScreen(PLAYER, view);
    const f = fractionOf(PLAYER, view);
    log(
      `on ${view.pixelWidth} by ${view.pixelHeight}, the player at (3, 2) is`,
      `pixel (${at.x}, ${at.y}) \u2014 ${(f.x * 100).toFixed(2)}% across, ${(f.y * 100).toFixed(2)}% down`,
      view.pixelWidth === 320
        ? "the pixels differ, the percentages do not"
        : undefined,
    );
  }

  // The same claim from the other side: a fixed pixel step is a different distance on each.
  for (const view of SIZES) {
    log(
      `moving 5 pixels on ${view.pixelWidth} by ${view.pixelHeight} covers`,
      `${pixelsInUnits(5, view).toFixed(4)} world units`,
      view.pixelWidth === 320
        ? "which is why speeds are not measured in pixels"
        : undefined,
    );
  }

  log(
    "so the same 5 pixels is",
    `${(pixelsInUnits(5, SIZES[0]) / pixelsInUnits(5, SIZES[2])).toFixed(1)}x further on the small screen`,
    `${pixelsPerUnit(SIZES[0])} pixels per unit against ${pixelsPerUnit(SIZES[2])}`,
  );
};

export default demo;
on 320 by 180, the player at (3, 2) is pixel (60, 140) — 18.75% across, 77.78% down // the pixels differ, the percentages do not
on 960 by 540, the player at (3, 2) is pixel (180, 420) — 18.75% across, 77.78% down
on 1920 by 1080, the player at (3, 2) is pixel (360, 840) — 18.75% across, 77.78% down
moving 5 pixels on 320 by 180 covers 0.2500 world units // which is why speeds are not measured in pixels
moving 5 pixels on 960 by 540 covers 0.0833 world units
moving 5 pixels on 1920 by 1080 covers 0.0417 world units
so the same 5 pixels is 6.0x further on the small screen // 20 pixels per unit against 120

Read the first three rows: one world point, three canvases, three completely different pixel positions — and identical percentages. Then the next three: the same 5 pixels is exactly six times further on the 320-wide canvas than on the 1920-wide one.

So do not store pixels. Decide how much world fits across the screen — “16 units wide” — and derive the pixel scale from the canvas:

s=canvas width in pixelsunits acrosss = \frac{\text{canvas width in pixels}}{\text{units across}}

Everything in the game is then measured in units. A speed of 6 units per second, a jump 3 units high, a platform 4 units wide. Resize the window, run it on a phone, and none of those numbers need to change. Only ss does, and only at the point of drawing.

The vertical extent follows rather than being chosen. Sixteen units across a 16:9 canvas is nine units down — the check pins that. It also pins the honest caveat: the percentages only match across canvases whose shape matches. Change the aspect ratio and a squarer canvas shows more world vertically, which is a real decision to make about your game rather than a bug.

source Screen space, world space, and the conversion between them src/lib/gamedev2d/screen.ts 114 lines
/**
 * Two coordinate systems that disagree about which way is up, and how to move between them.
 *
 * A canvas puts its origin in the **top-left** and counts Y **downward**, because that is how
 * screens have been scanned since television. Almost every piece of mathematics you will write
 * assumes the opposite: origin at the bottom-left, Y counting upward, the way graph paper works.
 *
 * You cannot make the canvas change its mind, and you should not try to do the maths in the
 * canvas's convention either - every angle, every "up", and every trigonometric identity you know
 * would need a sign flipping somewhere. Do the maths in world units with Y up, and convert once,
 * at the moment you draw. That single conversion is this file.
 */

/** Two numbers. In this module that is all a position or a direction ever is. */
export type Vec2 = { x: number; y: number };

/**
 * A world measured in **units**, drawn into a canvas measured in **pixels**.
 *
 * Storing `unitsAcross` rather than a pixel scale is what makes a game resolution independent. The
 * world is "16 units wide" whatever the canvas is, so the same numbers work on a phone and a
 * monitor, and nothing has to be retuned when the window resizes.
 */
export type View = {
  pixelWidth: number;
  pixelHeight: number;
  /** How much of the world fits across the canvas. The vertical extent follows from the shape. */
  unitsAcross: number;
};

/** How many pixels one world unit is worth. The only number that changes with resolution. */
export function pixelsPerUnit(view: View): number {
  return view.pixelWidth / view.unitsAcross;
}

/** How much world fits vertically. Falls out of the aspect ratio rather than being chosen. */
export function unitsDown(view: View): number {
  return view.pixelHeight / pixelsPerUnit(view);
}

/**
 * World to screen: scale into pixels, then flip Y.
 *
 * $$x_{\text{screen}} = x_{\text{world}} \cdot s \qquad y_{\text{screen}} = h - y_{\text{world}} \cdot s$$
 *
 * The subtraction is the whole conversion. A world Y of zero lands at the bottom of the canvas, and
 * growing world Y walks **up** the screen, which is to say toward smaller screen Y.
 */
export function worldToScreen(p: Vec2, view: View): Vec2 {
  const s = pixelsPerUnit(view);
  return { x: p.x * s, y: view.pixelHeight - p.y * s };
}

/** Screen to world: undo the flip, then undo the scale. Needed for every mouse click. */
export function screenToWorld(p: Vec2, view: View): Vec2 {
  const s = pixelsPerUnit(view);
  return { x: p.x / s, y: (view.pixelHeight - p.y) / s };
}

/**
 * Which way is up, in each convention. Worth having as a constant you can point at.
 *
 * The second one is the source of an enormous amount of confusion. **On a canvas you move something
 * up by subtracting from Y**, so a jump is `y -= speed` and gravity is `y += speed`, both of which
 * read backwards to anyone who has done any physics.
 */
export const WORLD_UP: Vec2 = { x: 0, y: 1 };
export const SCREEN_UP: Vec2 = { x: 0, y: -1 };

/**
 * A direction from an angle, the way trigonometry defines it: counter-clockwise from the +X axis.
 *
 * This is correct in world coordinates and is what the unit circle means. Section 2.2 leans on it.
 */
export function directionFromAngle(radians: number): Vec2 {
  return { x: Math.cos(radians), y: Math.sin(radians) };
}

/**
 * The same rotation, expressed for a canvas.
 *
 * Flipping Y also flips the sense of rotation: **counter-clockwise in the world is clockwise on
 * screen.** So an angle that turns a shape one way in your maths turns it the other way once drawn,
 * and the fix is a single minus sign applied at the drawing step rather than woven through the
 * maths.
 *
 * This is why `ctx.rotate(a)` appears to go the "wrong" way compared to the unit circle. It is not
 * wrong; it is measuring in a system where Y grows downward.
 */
export function worldAngleToScreen(radians: number): number {
  return -radians;
}

/**
 * The fraction of the way across and up the canvas a world point sits at.
 *
 * The point of this is that the fractions do **not** depend on the resolution, while the pixels do.
 * Two canvases of different sizes showing the same world put a point at different pixel coordinates
 * and the same fraction - which is the test of whether a layout is resolution independent.
 */
export function fractionOf(p: Vec2, view: View): Vec2 {
  const screen = worldToScreen(p, view);
  return { x: screen.x / view.pixelWidth, y: screen.y / view.pixelHeight };
}

/**
 * How far a fixed number of **pixels** is, measured in world units, on a given canvas.
 *
 * Handy for showing why "move 5 pixels" is a bug: the same 5 pixels is a different distance in the
 * world on every screen, so a game written that way plays differently at different resolutions.
 */
export function pixelsInUnits(pixels: number, view: View): number {
  return pixels / pixelsPerUnit(view);
}
  • Every sprite you draw, which is a world position converted once.
  • Every mouse click, which is a pixel converted the other way before it can mean anything.
  • Anything that jumps, where the sign of gravity depends entirely on which convention you chose.
  • Anything that aims or rotates, where a positive angle turns one way in your maths and the other on screen.
  • Supporting more than one screen size, which is free if you work in units and a rewrite if you work in pixels.
  • Section 1.2, which starts using these coordinates to talk about places and displacements.