DEV Community

3D Roll
3D Roll

Posted on

Farkle / Zehntausend scoring as a pure function: a small JavaScript kata

Farkle (known in Germany as Zehntausend, "ten thousand") is a push-your-luck dice game. It is also a great little kata: the scoring rules are small enough to fit in your head, yet full of edge cases. In this post we will write the scorer as a pure function, then use it to compute the famous bust probabilities.

The rules in 60 seconds

  • You roll six dice. You must set aside at least one scoring die (or combination).
  • You may then stop and bank your turn total, or re-roll the remaining dice.
  • If a roll contains no scoring dice, you bust (Farkle) and lose everything from this turn.
  • If all six dice score ("hot dice"), you may pick up all six and keep going.
  • The first player to reach 10,000 points wins.

Common scoring (house rules vary, which is exactly why a pure function is handy):

Combination Points
Single 1 100
Single 5 50
Three 1s 1000
Three of X (2-6) X × 100
Four / five / six of a kind 2× / 4× / 8× the triple value
Straight 1-2-3-4-5-6 1500
Three pairs 750

The scoring function

The function takes an array of die faces and returns the best score that uses all given dice, or 0 if some die doesn't score. No I/O, no randomness, no mutation.

const counts = (dice) => {
  const c = [0, 0, 0, 0, 0, 0, 0];
  for (const d of dice) c[d]++;
  return c;
};

const tripleValue = (face) => (face === 1 ? 1000 : face * 100);

export function score(dice) {
  const c = counts(dice);
  const n = dice.length;
  if (n === 6 && c.slice(1).every((x) => x === 1)) return 1500; // straight
  if (n === 6 && c.filter((x) => x === 2).length === 3) return 750; // three pairs
  let total = 0;
  for (let f = 1; f <= 6; f++) {
    let k = c[f];
    if (k >= 3) {
      total += tripleValue(f) * 2 ** (k - 3);
      k = 0;
    }
    if (k > 0) {
      if (f === 1) total += 100 * k;
      else if (f === 5) total += 50 * k;
      else return 0; // a non-scoring die was included
    }
  }
  return total;
}

// Does a roll contain *anything* that scores?
export const hasScore = (roll) => {
  const c = counts(roll);
  return c[1] > 0 || c[5] > 0 || c.some((x) => x >= 3) ||
    score(roll) > 0; // catches three pairs
};
Enter fullscreen mode Exit fullscreen mode

Some tests that pin down the behaviour:

console.assert(score([1]) === 100);
console.assert(score([5, 5]) === 100);
console.assert(score([2, 2, 2]) === 200);
console.assert(score([1, 1, 1, 1]) === 2000);
console.assert(score([1, 2, 3, 4, 5, 6]) === 1500);
console.assert(score([2, 2, 3, 3, 4, 4]) === 750);
console.assert(score([2, 3]) === 0);
Enter fullscreen mode Exit fullscreen mode

Bust probability with 1-6 dice

Because hasScore is pure, we can brute-force every outcome of n dice (6^n, at most 46,656) and count the busts:

function bustChance(n) {
  let busts = 0, total = 6 ** n;
  for (let i = 0; i < total; i++) {
    const roll = [];
    for (let j = 0, x = i; j < n; j++, x = Math.floor(x / 6)) roll.push((x % 6) + 1);
    if (!hasScore(roll)) busts++;
  }
  return busts / total;
}
Enter fullscreen mode Exit fullscreen mode
Dice left Bust chance
1 2/3 ≈ 66.7%
2 4/9 ≈ 44.4%
3 5/18 ≈ 27.8%
4 ≈ 15.7%
5 ≈ 7.7%
6 ≈ 2.3%

Sanity check for small n: with one die only a 1 or 5 scores, so the bust chance is 4/6 = 2/3. With two dice both must avoid 1 and 5: (4/6)² = 4/9. With three dice, (4/6)³ = 8/27, minus the triples of 2, 3, 4, 6 (4/216), giving 60/216 = 5/18.

The table explains the strategy most players learn the hard way: re-rolling one or two dice is risky, while re-rolling five or six is almost free.

Kata extensions

  • Make the scoring table a parameter so house rules become data.
  • Write bestSelection(roll) that returns the highest-scoring subset.
  • Compute the expected value of continuing vs. banking at a given turn total.

If you want to try the game with real-feeling virtual dice, roll them here: https://3droll.com/farkle-dice/

Top comments (0)