Skip to content
Ayotl

← How a game is made: Daily Challenge's Snake · Lesson 3/16

A 160 × 192 screen

✕
1/7

Virtual pixels

Every game draws on a 160 × 192 pixel screen, the Atari 2600's proportions. It doesn't matter whether your phone is 400 or 1400 pixels wide: the game only knows those 160.

In core/renderer.js the canvas is created at exactly that size and smoothing is turned off:

canvas.width = VW; canvas.height = VH; this.ctx.imageSmoothingEnabled = false;

And drawing is almost always just one thing: a colored rectangle, rect(x, y, w, h, color). The program imitates that renderer with characters: each one stands for a 10 × 10 pixel block.

leccion.js

$ node leccion.js

Press RUN (or Ctrl+Enter).

What to look at

You get 16 columns by 20 rows of characters: 160 × 192 divided by 10. The score bar is 16 pixels tall, so the board starts at pixel 16, halfway through a character: the snake (at pixel 96, snake_01's real start) and the fruit each take up two rows. The real screen doesn't have that problem; the character drawing is just coarser. Move the fruit by changing the 120 and the 56.

Canvas tutorial (MDN) ↗

The whole lesson on one page

Virtual pixels

Every game draws on a 160 × 192 pixel screen, the Atari 2600's proportions. It doesn't matter whether your phone is 400 or 1400 pixels wide: the game only knows those 160.

In core/renderer.js the canvas is created at exactly that size and smoothing is turned off:

canvas.width = VW; canvas.height = VH; this.ctx.imageSmoothingEnabled = false;

And drawing is almost always just one thing: a colored rectangle, rect(x, y, w, h, color). The program imitates that renderer with characters: each one stands for a 10 × 10 pixel block.

const VW = 160, VH = 192, BLOQUE = 10;

// A fake renderer: draws characters instead of pixels
class Renderer {
  constructor() {
    this.filas = Array.from({ length: Math.ceil(VH / BLOQUE) }, () => Array(VW / BLOQUE).fill(" "));
  }
  rect(x, y, w, h, color) {
    for (let fy = Math.floor(y / BLOQUE); fy < Math.ceil((y + h) / BLOQUE); fy++)
      for (let fx = Math.floor(x / BLOQUE); fx < Math.ceil((x + w) / BLOQUE); fx++)
        if (this.filas[fy] && fx < this.filas[fy].length) this.filas[fy][fx] = color;
  }
  clear(color) { this.rect(0, 0, VW, VH, color); }
  mostrar() { for (const f of this.filas) console.log("|" + f.join("") + "|"); }
}

const r = new Renderer();
r.clear(".");
r.rect(0, 0, 160, 16, "=");      // the score bar at the top
r.rect(70, 96, 30, 10, "s");     // three snake cells
r.rect(120, 56, 10, 10, "f");    // a fruit
r.mostrar();

You get 16 columns by 20 rows of characters: 160 × 192 divided by 10. The score bar is 16 pixels tall, so the board starts at pixel 16, halfway through a character: the snake (at pixel 96, snake_01's real start) and the fruit each take up two rows. The real screen doesn't have that problem; the character drawing is just coarser. Move the fruit by changing the 120 and the 56.

Scaling up without blur

160 pixels on a phone would be a postage stamp. So the canvas is scaled up with CSS, but only by whole numbers: ×2, ×3, ×4. At ×2.5, some pixels would come out 2 wide and others 3, and the 3 × 5 letters would look crooked.

This is fit(), exactly as it is in the renderer:

const s = Math.max(1, Math.floor(Math.min(availW / VW, availH / VH)));

It finds the scale that fits horizontally and vertically, keeps the smaller one and rounds it down. And the canvas CSS has image-rendering: pixelated, so the browser enlarges each pixel as a hard square instead of smearing it.

const VW = 160, VH = 192;
function fit(availW, availH) {
  return Math.max(1, Math.floor(Math.min(availW / VW, availH / VH)));
}

const pantallas = [
  ["small phone", 360, 560],
  ["large phone", 412, 760],
  ["tablet", 820, 1000],
  ["laptop", 1366, 680],
];
for (const [nombre, w, h] of pantallas) {
  const s = fit(w, h);
  console.log(nombre.padEnd(16), "x" + s, "->", VW * s + "x" + VH * s, "px");
}

On the laptop the height decides: 680 / 192 is 3.5, which rounds down to ×3. On the small phone the width decides: 360 / 160 = 2.25, ×2. A bit of space is left over at the sides; that's the price of even pixels.

Where each cell lands

Snake doesn't think in pixels but in cells. At the top of snake.js are its measurements:

export const CELL = 10, COLS = 16, ROWS = 17, HUD = 16;

  • 16 columns of 10 pixels = 160: exactly the screen's width.
  • 17 rows of 10 = 170, plus the 16 of the score bar (HUD) = 186, which fits in 192.

To paint cell (x, y) it's converted to pixels: x * CELL across and HUD + y * CELL down, because the board starts below the score bar. This is how render() paints the body:

r.rect(b.x * CELL, HUD + b.y * CELL, CELL, CELL, color)

A comment in the file explains why 10 pixels: it "looks better on a phone than 20x22 at 8px".

const CELL = 10, COLS = 16, ROWS = 17, HUD = 16;

function aPixel(casilla) {
  return { px: casilla.x * CELL, py: HUD + casilla.y * CELL };
}

console.log("top corner:", aPixel({ x: 0, y: 0 }));
console.log("snake_01 start:", aPixel({ x: 7, y: 8 }));
console.log("bottom corner:", aPixel({ x: COLS - 1, y: ROWS - 1 }));
console.log("height used:", HUD + ROWS * CELL, "of 192");

The last cell starts at pixel 176 and ends at 185: 6 pixels are left over at the bottom. The game logic never talks about pixels; only render does the math.