← How a game is made: Daily Challenge's Snake · Lesson 3/16
A 160 × 192 screen
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.
$ 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.
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.