Skip to content
Ayotl

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

One bitmask per tick

✕
1/8

Five buttons, five bits

The Atari had a four-way joystick and one button. Daily Challenge stores all five in a single number, one bit per button. That's all of core/buttons.js:

export const BTN = { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8, A: 16 };

Each value is a power of 2, so they can be combined without clashing: up and right together is 1 | 8 = 9. To ask whether a button is pressed you use bitwise and: mask & BTN.UP is non-zero if the up bit is on. Five bits give 32 combinations: from 0 (nothing) to 31 (everything).

leccion.js

$ node leccion.js

Press RUN (or Ctrl+Enter).

What to look at

The middle column is the number in binary: each 1 is a pressed button. 25 is 11001: A, right and up.

The & operator (MDN) ↗

The whole lesson on one page

Five buttons, five bits

The Atari had a four-way joystick and one button. Daily Challenge stores all five in a single number, one bit per button. That's all of core/buttons.js:

export const BTN = { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8, A: 16 };

Each value is a power of 2, so they can be combined without clashing: up and right together is 1 | 8 = 9. To ask whether a button is pressed you use bitwise and: mask & BTN.UP is non-zero if the up bit is on. Five bits give 32 combinations: from 0 (nothing) to 31 (everything).

const BTN = { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8, A: 16 };

function nombres(mask) {
  const lista = Object.keys(BTN).filter(b => mask & BTN[b]);
  return lista.length ? lista.join("+") : "nothing";
}

for (const m of [0, 1, 9, 16, 25, 31]) {
  console.log(String(m).padStart(2), m.toString(2).padStart(5, "0"), nombres(m));
}
console.log("up + right =", BTN.UP | BTN.RIGHT);

The middle column is the number in binary: each 1 is a pressed button. 25 is 11001: A, right and up.

Holding and tapping: held and latched

core/input.js merges three sources into the same bitmask: the keyboard (arrows, WASD, space), the on-screen D-pad and swipes over the game. It keeps them in two places:

  • held: what's pressed right now. A key turns its bit on when it goes down and off when it comes up.
  • latched: what happened just once, like a swipe. It's kept until the next tick and then cleared, so a lightning-fast swipe between two ticks isn't lost.

Once per tick, the loop calls poll():

const m = this.held | this.latched; this.latched = 0; return m;

const BTN = { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8, A: 16 };
const input = {
  held: 0, latched: 0,
  bajaTecla(b) { this.held |= b; },
  subeTecla(b) { this.held &= ~b; },
  swipe(b)     { this.latched |= b; },
  poll() { const m = this.held | this.latched; this.latched = 0; return m; },
};

const log = [];
input.bajaTecla(BTN.RIGHT);  log.push(input.poll());   // tick 1
                             log.push(input.poll());   // tick 2: still held
input.swipe(BTN.UP);         log.push(input.poll());   // tick 3: the swipe
                             log.push(input.poll());   // tick 4: already cleared
input.subeTecla(BTN.RIGHT);  log.push(input.poll());   // tick 5
console.log(log);

[8, 8, 9, 8, 0]: right stays on while the key is down; the upward swipe shows up exactly once (8 + 1 = 9) and goes away. ~b flips the bits: held &= ~b turns off just that button.

Pressing isn't holding

Snake only needs to know what's held: if (mask & BTN.UP && this.dir.y !== 1) …. But other games need the moment a button goes down: in Tetris, holding the button shouldn't rotate the piece 30 times a second. For that, buttons.js has a second function:

export const pressed = (mask, prev) => mask & ~prev;

It returns the bits that are on now but weren't on the previous tick: the edges.

const pressed = (mask, prev) => mask & ~prev;

// A held for three ticks, released and pressed again
const masks = [0, 16, 16, 16, 0, 16];
let prev = 0, giros = 0;
for (const m of masks) {
  if (pressed(m, prev) & 16) giros++;
  prev = m;
}
console.log("ticks with A:", masks.filter(m => m & 16).length, "| rotations:", giros);

Four ticks with A down, but only two rotations: one for each time the button went down.

The whole game, as text

Every tick, main.js saves the bitmask before handing it to the game: log.push(mask); game.update(mask);. At the end, the whole log is an array of numbers from 0 to 31, one per tick.

To send it to the server, net.js turns each number into a character by adding 48 (the code of '0'):

log.map(m => String.fromCharCode(48 + m)).join('')

A one-minute game is 1800 characters. The server does the reverse with charCodeAt(0) - 48 and rejects the log if any number falls outside 0 to 31.

const log = [0, 0, 8, 8, 9, 1, 1, 16, 0];
const texto = log.map(m => String.fromCharCode(48 + m)).join("");
console.log("for the server:", texto);

// on the server's side
const deVuelta = Array.from(texto, ch => ch.charCodeAt(0) - 48);
console.log("read back:", deVuelta);
console.log("valid?", deVuelta.every(m => m >= 0 && m <= 31));
console.log("one minute =", 30 * 60, "characters");

0 to 9 come out as digits, 10 to 15 as symbols like : and ?, 16 is @ and 17 to 31 are the letters A to O. One character per tick: compact and easy to check.