DEV Community

杜涛
杜涛

Posted on

How I Built a 24 Game Solver: Brute-Force Meets Elegance in TypeScript

The 24 Game is deceptively simple: given four numbers, combine them with +, -, ×, ÷ to make exactly 24. Sounds easy, right? Try solving 1, 5, 5, 5 — it stumps most people.

I built a complete solver for my math puzzle platform, and the algorithm turned out to be a great exercise in combinatorics, expression trees, and floating-point traps. Let me walk you through it.


The Problem Space

Four numbers, four operators, and parentheses. How many possibilities?

  • Permutations of 4 numbers: up to 24 (4!)
  • Operator combinations: 4³ = 64 (three slots, four choices each)
  • Parenthesization patterns: 5 (the Catalan number C₃)

Total: 24 × 64 × 5 = 7,680 evaluations. Trivial for modern hardware.

The key insight is that we need to enumerate all five binary tree structures for combining four operands — these correspond to the five ways to fully parenthesize a sequence of four items.


The Five Expression Patterns

For numbers a, b, c, d and operators op1, op2, op3:

Pattern 1: ((a op1 b) op2 c) op3 d
Pattern 2: (a op1 (b op2 c)) op3 d
Pattern 3: (a op1 b) op2 (c op3 d)
Pattern 4: a op1 ((b op2 c) op3 d)
Pattern 5: a op1 (b op2 (c op3 d))
Enter fullscreen mode Exit fullscreen mode

These five patterns cover every possible way to combine four numbers with binary operators. No expression is left unexplored.


The Algorithm in TypeScript

Step 1: Generate Permutations (with deduplication)

When input contains duplicates (like 5, 5, 5, 1), many permutations are identical. We use a Set to filter:

function permutations(arr: number[]): number[][] {
  if (arr.length <= 1) return [arr];

  const result: number[][] = [];
  const seen = new Set<string>();

  for (let i = 0; i < arr.length; i++) {
    const rest = [...arr.slice(0, i), ...arr.slice(i + 1)];
    for (const perm of permutations(rest)) {
      const newPerm = [arr[i], ...perm];
      const key = newPerm.join(',');
      if (!seen.has(key)) {
        seen.add(key);
        result.push(newPerm);
      }
    }
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

For 5, 5, 5, 1, this reduces 24 permutations down to just 4.

Step 2: Evaluate Each Pattern

The core loop iterates over all permutations × operators × patterns:

const operators = ['+', '-', '*', '/'];

function operate(a: number, b: number, op: string): number {
  switch (op) {
    case '+': return a + b;
    case '-': return a - b;
    case '*': return a * b;
    case '/': return b !== 0 ? a / b : Infinity;
    default: return Infinity;
  }
}
Enter fullscreen mode Exit fullscreen mode

For each pattern, we evaluate step by step, short-circuiting on division by zero or invalid results:

// Pattern 3: (a op1 b) op2 (c op3 d)
try {
  const r1 = operate(a, b, op1);
  if (!isValidNum(r1)) throw new Error();

  const r2 = operate(c, d, op3);
  if (!isValidNum(r2)) throw new Error();

  const r3 = operate(r1, r2, op2);
  if (!isValidNum(r3)) throw new Error();

  if (Math.abs(r3 - 24) < 0.0001) {
    // Found a solution!
  }
} catch {}
Enter fullscreen mode Exit fullscreen mode

Step 3: Handle Floating-Point Precision

This is the tricky part. When 8 / (3 - 8/3) = 24, the intermediate results involve fractions. Direct equality check (r3 === 24) will fail due to floating-point errors.

Solution: use an epsilon comparison:

if (Math.abs(r3 - 24) < 0.0001) {
  // This is a valid solution
}
Enter fullscreen mode Exit fullscreen mode

This tiny tolerance (0.0001) handles all the edge cases while avoiding false positives.


Putting It All Together

export function solve24(numbers: number[]): Solution[] {
  const solutions: Solution[] = [];
  const seen = new Set<string>();
  const perms = permutations(numbers);

  for (const [a, b, c, d] of perms) {
    for (const op1 of operators) {
      for (const op2 of operators) {
        for (const op3 of operators) {
          // Evaluate all 5 patterns...
          // (full implementation above)
        }
      }
    }
  }

  return solutions.slice(0, 10); // Return top 10 solutions
}
Enter fullscreen mode Exit fullscreen mode

The full solver runs in under 1ms for any input — brute force is perfectly fine here.


Tricky Examples

Input Solution
1, 5, 5, 5 5 × (5 - 1/5) = 24
3, 3, 8, 8 8 / (3 - 8/3) = 24
1, 3, 4, 6 6 / (1 - 3/4) = 24

These are the puzzles that make people give up — and exactly why having a solver is satisfying.


Try It Live

I integrated this solver into my math puzzle platform. You can play the 24 Game with a built-in hint system that uses this exact algorithm:

👉 Play 24 Game on MathPuzzleHub

The solver powers the "Show Solution" button — it finds all valid expressions and displays them step by step.


Performance Notes

  • Worst case (4 distinct numbers like 1, 2, 3, 4): ~7,680 evaluations, still under 1ms
  • Best case (all same like 6, 6, 6, 6): only 1 unique permutation, 320 evaluations
  • Memory: minimal — just storing up to 10 solution strings

If you wanted to scale this to 5 or 6 numbers, you'd want to switch from brute force to a recursive reduction approach (pick any two numbers, combine them, recurse on the smaller set). But for the classic 4-number game, brute force is elegant and fast.


Key Takeaways

  1. Brute force is underrated. 7,680 evaluations is nothing. Don't over-engineer.
  2. Five parenthesization patterns cover all binary expression trees for 4 operands — this comes from Catalan numbers.
  3. Floating-point epsilon comparison is essential. === 24 will miss valid solutions involving division.
  4. Deduplication matters both for performance (fewer permutations) and UX (no duplicate solutions shown).

Happy solving! 🧮


Built with TypeScript and Next.js. Check out more math games at MathPuzzleHub.

Top comments (0)