DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

SET Is Not a Card Game, It Is Four-Dimensional Arithmetic Wearing a Deck of 81 Cards

SET is the little deck with the coloured squiggles: eighty-one cards, four attributes, and one rule that everybody learns as two rules. Deal twelve cards, find three where each attribute is all the same or all different, shout, take them, refill.

I rebuilt it in vanilla JavaScript with real inline SVG cards, and the whole thing turned out to be a lesson in choosing a representation. Get that one decision right and the rule collapses to a modular sum, the third card of any pair becomes a subtraction, and famous numbers like "1080 sets in the deck" stop being trivia and become one line of arithmetic.

A card is a number, not a picture

The deck has eighty-one cards because there are four attributes with three values each, and 3 × 3 × 3 × 3 = 81. That is not a fun fact printed on the box. It is the design of the game, and if you store a card as {count: 2, shape: "oval", shading: "striped", colour: "red"} you have thrown it away on line one.

Store the integer instead, and read it as four digits in base 3.

// digit 0 = count (1,2,3), 1 = shape, 2 = shading, 3 = colour
const attrsOf = id => [ ((id / 27) | 0) % 3, ((id / 9) | 0) % 3, ((id / 3) | 0) % 3, id % 3 ];
const idOf    = a  => a[0] * 27 + a[1] * 9 + a[2] * 3 + a[3];

const makeDeck = () => Array.from({ length: 81 }, (_, i) => i);
Enter fullscreen mode Exit fullscreen mode

Card 34 is [1, 0, 2, 1]: two diamonds, open, green. The deck is not "81 cards" — it is the number system, and every rule below is arithmetic on those digits.

One rule, not two

Beginners hear the rule as a pair of alternatives and check them separately, which is how you end up with a tangle of conditions. The cleaner way to hear it is negative: a triple fails only when an attribute shows two of one value and one of another. Three diamonds is fine. Three different shapes is fine. Two squiggles and an oval is not.

function isSet(a, b, c) {
  const A = attrsOf(a), B = attrsOf(b), C = attrsOf(c);
  for (let k = 0; k < 4; k++) {
    const x = A[k], y = B[k], z = C[k];
    const allSame = (x === y && y === z);
    const allDiff = (x !== y && y !== z && x !== z);
    if (!allSame && !allDiff) return false;   // two-and-one is the ONLY failure
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Attributes never interact — the shapes have no opinion about the colours — so the loop can bail out the instant any single attribute is a two-and-one.

The identity that changes everything

Now the observation that turns a card game into linear algebra. Take three digits from {0,1,2}:

  • all equal: 0+0+0 = 0, 1+1+1 = 3, 2+2+2 = 6
  • all distinct: 0+1+2 = 3
  • anything else — a two-and-one — sums to 1, 2, 4 or 5

So "all the same or all different" is exactly "the sum is divisible by three". One condition instead of two, with no comparisons at all.

function isSetMod3(a, b, c) {
  const A = attrsOf(a), B = attrsOf(b), C = attrsOf(c);
  for (let k = 0; k < 4; k++)
    if ((A[k] + B[k] + C[k]) % 3 !== 0) return false;
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Cards are points in a four-dimensional space over the field with three elements, and a set is a line in that space: three points summing to zero. I kept both functions in the file and asserted they agree on all C(81,3) = 85,320 triples. They do, all 85,320 of them, in about eighty milliseconds. That test is the actual proof, and it is worth more than any comment I could write above the function.

Two cards force the third

If a + b + c ≡ 0, then c ≡ −a − b. Digit by digit, adding 6 first so JavaScript's % never sees a negative:

function thirdCard(a, b) {
  const A = attrsOf(a), B = attrsOf(b), out = [0, 0, 0, 0];
  for (let k = 0; k < 4; k++) out[k] = (6 - A[k] - B[k]) % 3;
  return idOf(out);
}
Enter fullscreen mode Exit fullscreen mode

This is the theorem that makes SET playable, and it should change how you play. When you are staring at two cards, do not scan the board hoping to recognise a match. Construct the exact card you need, then look for that one specific card.

It also hands you both famous numbers for free. There are C(81,2) = 3,240 pairs, every pair belongs to exactly one set, and every set contains three pairs — so the deck holds 3,240 / 3 = 1,080 sets. Fix one card: it pairs with the other 80, and each set through it uses two of those partners, so it lies in 80 / 2 = 40 sets.

I still brute-force both on page load rather than printing them, because a derivation you can check against a loop beats a derivation you have to trust.

The board is 220 triples, so just look at all of them

Twelve cards give C(12,3) = 220 triples, and checking one triple is sixteen additions. That is a rounding error next to a single frame of animation. The correct engineering answer is to enumerate the lot on every click and never think about it again.

function findSets(board) {
  const out = [];
  for (let i = 0; i < board.length; i++)
    for (let j = i + 1; j < board.length; j++)
      for (let k = j + 1; k < board.length; k++)
        if (isSet(board[i], board[j], board[k])) out.push([i, j, k]);
  return out;
}
const countSets = b => findSets(b).length;
const hasSet    = b => findSets(b).length > 0;
Enter fullscreen mode Exit fullscreen mode

One enumerator, three features: the live "sets on board" counter, the deal-three-more trigger, and the hint. Being clever here — indexing by pairs, caching between deals — would buy microseconds and cost correctness bugs. If I ever needed to scale, the pair-first version is the move: loop over pairs, compute the forced third card, look it up. That is C(n,2) work instead of C(n,3).

Dead boards, measured rather than quoted

Every so often twelve cards contain nothing at all, and the game's answer is to deal three more. The interesting part is that you cannot eyeball this — a board can look busy and be completely dead — so the rule is only implementable because the detector is exact.

function topUp(state) {
  while (state.board.length < 12 && state.deck.length) state.board.push(state.deck.pop());
  while (!hasSet(state.board) && state.deck.length >= 3)
    for (let i = 0; i < 3; i++) state.board.push(state.deck.pop());
}
Enter fullscreen mode Exit fullscreen mode

How often does it happen? I did not want to repeat a number off a forum, so the page deals thousands of random boards on load and counts:

function measureDeadBoardOdds(n, trials) {
  let dead = 0;
  for (let t = 0; t < trials; t++) if (!hasSet(shuffle(makeDeck()).slice(0, n))) dead++;
  return dead / trials;
}
Enter fullscreen mode Exit fullscreen mode

Twelve cards come out dead about 3.2% of the time — roughly one deal in thirty-one, so a twelve-card board contains a set about 96.8% of the time. By fifteen cards the dead rate is down near four hundredths of a per cent, which is why you see fifteen-card boards occasionally and eighteen almost never.

Refill in place, or the game feels broken

When a set is claimed, the obvious move is to splice the three cards out and push three new ones onto the end. Do that and players hate you, because every remaining card shifts position and the half-formed pattern they were holding in their head evaporates.

function claim(state, idx) {
  const sorted = idx.slice().sort((p, q) => p - q);
  if (state.board.length > 12 || state.deck.length === 0) {
    for (let i = sorted.length - 1; i >= 0; i--) state.board.splice(sorted[i], 1);
  } else {
    for (const p of sorted) state.board[p] = state.deck.pop();   // in place
  }
  topUp(state);
}
Enter fullscreen mode Exit fullscreen mode

Overwrite the three slots. Only genuinely remove them when the board has grown past twelve, or when the deck has run dry.

Say which attribute broke

A buzz and a red flash teaches nothing, and SET is a game people are actively learning while they play. The failing attribute is already known at the moment the test returns false — so return it instead of a bare boolean.

function brokenAttribute(a, b, c) {
  const A = attrsOf(a), B = attrsOf(b), C = attrsOf(c);
  for (let k = 0; k < 4; k++) {
    const x = A[k], y = B[k], z = C[k];
    if ((x === y && y === z) || (x !== y && y !== z && x !== z)) continue;
    return { index: k, name: ATTR_NAMES[k],
             values: [x, y, z].map(v => ATTR_VALUES[k][v]),
             sum: (x + y + z) % 3 };      // how far it missed a multiple of 3
  }
  return null;                            // null means it IS a set
}
Enter fullscreen mode Exit fullscreen mode

Four extra lines buy you "the colour is red, red, purple: two red and one purple" instead of a shrug. For a beginner it is the single highest-value thing in the file.

The cap set problem, verified in the browser

Turn the game inside out and ask the mathematician's question: what is the largest board that contains no set at all? In this four-attribute deck the answer is exactly 20, proved by Pellegrino in 1971. These "cap sets" are a real research subject — the growth rate in high dimensions resisted attack for decades until a 2016 breakthrough using the polynomial method — and this little card game is its friendliest instance.

Hardcoding twenty card numbers proves nothing, so the page checks them in front of you:

const CAP20 = [0,1,3,4,9,10,12,13,27,28,32,35,38,47,59,65,66,67,71,77];

function verifyCap(board) {
  let triples = 0, sets = 0;
  for (let i = 0; i < board.length; i++)
    for (let j = i + 1; j < board.length; j++)
      for (let k = j + 1; k < board.length; k++) { triples++; if (isSet(board[i],board[j],board[k])) sets++; }
  let outside = 0, forced = 0;
  for (let c = 0; c < 81; c++) {
    if (board.indexOf(c) !== -1) continue;
    outside++;
    if (hasSet(board.concat([c]))) forced++;     // every outsider creates a set
  }
  return { triples, sets, outside, forced };     // { 1140, 0, 61, 61 }
}
Enter fullscreen mode Exit fullscreen mode

All 1,140 triples, zero sets, in about four milliseconds. Then all 61 cards outside the cap, every one of which creates a set — so no twenty-first card fits. And that is a fact about the physical game: since 21 cards can never avoid a set, a real SET board never needs more than 21 cards dealt.

Drawing 81 cards with zero images

There are three shapes, so draw each once in a 200×100 box and place it one, two or three times down the card with a transform. A diamond is a polygon, an oval is a rounded rectangle with a huge corner radius, and the squiggle is a closed bezier path.

Shading is where people reach for image files, and they should not. Solid is a plain fill, open is fill="none" with a stroke, and striped is a genuine SVG pattern defined once in a hidden defs block:

<svg width="0" height="0"><defs>
  <pattern id="stripe0" patternUnits="userSpaceOnUse" width="20" height="20">
    <rect width="20" height="20" fill="#fff"/>
    <path d="M3,0 V20" stroke="#dc2626" stroke-width="7"/>
  </pattern>
</defs></svg>
Enter fullscreen mode Exit fullscreen mode
function shapeEl(shape, shade, colorIdx) {
  const colour = HEX[colorIdx];
  const fill = shade === 0 ? colour
             : shade === 1 ? "url(#stripe" + colorIdx + ")"
                           : "none";
  const attr = 'fill="' + fill + '" stroke="' + colour + '" stroke-width="9"';
  if (shape === 0) return "<polygon points='100,6 194,50 100,94 6,50' " + attr + "/>";
  if (shape === 2) return "<rect x='6' y='6' width='188' height='88' rx='44' " + attr + "/>";
  return "<path d='" + SQUIGGLE + "' " + attr + "/>";
}
Enter fullscreen mode Exit fullscreen mode

Because the pattern lives in user space it scales with the transform, so the stripes stay in proportion at every card size. Counts are just y-centres: [90], [62,118], [34,90,146] inside a 120×180 viewBox. Eighty-one cards, no image files, no emoji.

What I actually verified

I pulled the engine out of the page and ran it against an independent implementation written a different way — a different decode (repeated division) and a different test (new Set([x,y,z]).size === 2 means two-and-one). 192,641 assertions, zero failures:

  • the mod-3 test and the all-same/all-different test agree on all 85,320 triples
  • brute force finds exactly 1,080 sets, and each of the 81 cards is in exactly 40
  • thirdCard(a, b) matches an exhaustive search for all 3,240 pairs, and exactly one completion exists each time
  • the board detector agrees with brute force on thousands of random 12-, 15-, 18- and 21-card boards
  • the 20-card cap really has zero sets over its 1,140 triples, and all 61 outsiders force one

That last bullet is my favourite kind of test: a 1971 theorem, checked by a loop, in a browser tab.

The takeaway

One decision — a card is a four-digit number in base 3 — deleted almost all of the code. The rule became a modular sum with no branches, the third card became a subtraction, the deck's 1,080 sets became arithmetic instead of trivia, and a 220-triple loop turned out to be cheap enough to power the set counter, the dead-board detector, the hint and the auto-solver all at once.

Play it, watch both rule-tests agree on your own selection, and verify the cap set yourself: https://dev48v.infy.uk/game/day61-set-card-game.html

Top comments (0)