IRCPuzzles is an annual IRC-based puzzle game that I eagerly anticipate every year. The challenges span a wide array of domains, including logical puzzles, cryptography, steganography, and much more.

This year’s event, which took place in April, provided its usual assortment of engaging challenges. Although it’s been a couple months, the experiences remain vivid in my memory. I’ll be sharing write-ups for other levels too, but I wanted to start with a particularly elegant challenge that warranted a blog post of its own.

Cracking the Tetris Clone

The challenge was hosted at this URL. Upon visiting the link, I was greeted with a game:

Gameplay

Gameplay

At first glance, it resembled the familiar Tetris game, but something was noticeably off. Instead of the usual four blocks per piece, this game featured five (the five-piece blocks are called pentominoes). It’s like someone decided Tetris was too easy and needed more chaos.

Curious about the mechanics, I decided to inspect the source code. The code was impressively elegant, and one part, in particular, caught my attention:

var rngState = 1;
function rngNext() {
  var res = rngState;
  rngState = (1202*rngState + 954) % 1469;
  return res;
}

function spawnCurrent() {
  var choice = nextChoice;
  nextChoice = rngNext() % 12;
  if (!nextChoice) {
    rngState = 1;
    nextChoice = rngNext() % 12;
  }
  var shape = SHAPES[choice];
  current = {
    shape: shape,
    value: 2 + choice,
    x: (WIDTH - SHAPES[choice][0].length) / 2 | 0,
    y: 0,
  };
}

The RNG (Random Number Generator) determined the order of the pentominoes. The line rngState = (1202*rngState + 954) % 1469 intrigued me—why not use Math.random()? I reviewed the rest of the code.

The shapes were defined in an array:

var SHAPES = [
    shape('@@. .@. .@@'),
    shape('..@.. ..@.. ..@.. ..@.. ..@..'),
    shape('..@. .@@. ..@. ..@.'),
    shape('..@. ..@. .@@. .@..'),
    shape('@@@ .@. .@.'),
    shape('@.. @.. @@@'),
    shape('@.@ @@@ ...'),
    shape('.@@ .@@ .@.'),
    shape('@.. @@. .@@'),
    shape('.@. @@@ .@.'),
    shape('.@@ @@. .@.'),
    shape('.@.. .@.. .@.. .@@.'),
];

After playing the game a few times, I noticed the shapes appeared in the same sequence every time. Déjà vu? The code snippet nextChoice = rngNext() % 12 confirmed that the RNG was controlling the shape order. And there were 12 shapes.

From the Pentomino Wikipedia page, I discovered a labeling scheme for pentominoes:

Image from Wikipedia showing the labelling scheme used for pentominoes

Image from Wikipedia showing the labelling scheme used for pentominoes

This mapping made sense—the game shapes corresponded to letters! For example, the letter I was represented by:

..@..
..@..
..@..
..@..
..@..

I wasn’t about to translate these by hand, so I used the image to map the shapes to their respective letters:

var SHAPE_TO_LETTER_MAP = {
  'Z': shape('@@. .@. .@@'),
  'I': shape('..@.. ..@.. ..@.. ..@.. ..@..'),
  'Y': shape('..@. .@@. ..@. ..@.'),
  'N': shape('..@. ..@. .@@. .@..'),
  'T': shape('@@@ .@. .@.'),
  'V': shape('@.. @.. @@@'),
  'U': shape('@.@ @@@ ...'),
  'P': shape('.@@ .@@ .@.'),
  'W': shape('@.. @@. .@@'),
  'X': shape('.@. @@@ .@.'),
  'F': shape('.@@ @@. .@.'),
  'L': shape('.@.. .@.. .@.. .@@.')
};

Next, I had to make the game spit out the letter of the current shape. A little JavaScript magic later, I had this:

function getShapeLetter(shape) {
  var shapeStr = shape.map(row => row.map(cell => cell ? '@' : '.').join('')).join(' ');
  for (var letter in SHAPE_TO_LETTER_MAP) {
    var letterShapeStr = SHAPE_TO_LETTER_MAP[letter].map(row => row.map(cell => cell ? '@' : '.').join('')).join(' ');
    if (shapeStr === letterShapeStr) {
      return letter;
    }
  }
  return '?'; // In case no match is found
}


function spawnCurrent() {
  var choice = nextChoice;
  nextChoice = rngNext() % 12;
  if (!nextChoice) {
    rngState = 1;
    nextChoice = rngNext() % 12;
  }
  var shape = SHAPES[choice];
  current = {
    shape: shape,
    value: 2 + choice,
    x: (WIDTH - SHAPES[choice][0].length) / 2 | 0,
    y: 0,
  };

  // Print the corresponding letter
  var letter = getShapeLetter(shape);
  console.log('Current shape letter:', letter);
}

It modifies the already-existing function used in the game with my definition, which looks up the current shape in the mapping array and prints it.

With this nifty script in place, I executed it in the browser console, and played the game for a couple of minutes. And voilà:

Browser console showing the decoded text

Browser console showing the decoded text

It kept repeating the string infinitylupin over and over. I submitted infinitylupin as the answer and bingo! It was correct. In hindsight, I could’ve done the same thing manually, but where’s the fun in that?

How it works

The puzzle author used an LCG here. An LCG, or Linear Congruential Generator, is a type of pseudorandom number generator algorithm. It’s widely used due to its simplicity and ability to generate a sequence of numbers that appear random.

An LCG generates a sequence of numbers based on a simple mathematical formula:

$$ X_{n+1} = (aX_n + c) \mod m $$

Here’s what each term means:

  • $X_{n}$ is the current number in the sequence (also called the “seed” or “state”).
  • $X_{n+1}$ is the next number in the sequence.
  • $a$ is a multiplier.
  • $c$ is an increment.
  • $m$ is the modulus.

Process

  1. The process starts with an initial value called the seed (rngState in our code).
  2. Using the formula $(a \cdot X_n + c) \mod m$, the next number in the sequence is calculated.
  3. The new number becomes the current state for the next iteration.

In this puzzle, the LCG parameters were: a = 1202, c = 954, m = 1469.

The author likely bruteforced these values.

The initial state (rngState) starts at 1. Each time rngNext() is called, it computes the next state using the formula and returns it. This allows generating numbers that are seemingly random, but are not.

They also cleverly chose the parameters for the LCG that would give 0 as the 13th element in the sequence. This allowed them to reset the RNG without raising suspicions (!nextChoice would only return true for 0):

if (!nextChoice) {
    rngState = 1;
    nextChoice = rngNext() % 12;
}

This puzzle was an absolute blast. It really was “loopin’” in every sense of the word. Kudos to FireFly for creating this challenge.