Dev.to AI 🤖 Ai 👁 0 📖 3 min read

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 pl

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))

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;
}

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;
  }
}

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 {}

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
}

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
}

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.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.