Skip to main content
SetValidator is the most critical domain service in the game. Every player claim, every AI decision, every board-expansion check flows through it. A bug here does not just corrupt one feature — it corrupts every game mode, local and online, simultaneously. Read this page before touching any claim or board-management code.
Pre-production notice. SET: 3D Edition is in pre-production. The interfaces and implementation sketches below reflect the current design specification. The determinism requirement (bitwise-identical results on client and server) is a hard constraint that must be respected from the first commit.

The Rule

A Set is exactly three cards where, for each of the four attributes individually, the three values are either all the same or all different. Two cards sharing a value while the third card has a different value — “mixed” — makes the entire triple invalid, regardless of how the other attributes look. The validator checks all four attributes independently. An attribute passes its check only if the triple is all-same or all-different — never a mix of the two.

Interface Contract

SetValidator (the concrete implementation) is a stateless singleton — it holds no instance state and every method is a pure function. Inject it via ISetValidator into GameSession and any test that exercises claim logic.

Implementing Validate()

The method returns on the first failing attribute and names it in InvalidReason. During development and hint display this string is useful; in production builds InvalidReason can be omitted or replaced with an enum code.
IsValidAttribute<T> uses generic Enum constraints so the same helper works for all four attribute types. If profiling later reveals that the generic constraint boxes the enum values, replace the generic with four concrete overloads taking int (cast each enum to int at the call site). The logic is identical.

Implementing FindAllSets()

FindAllSets enumerates every unique 3-card combination from the supplied list and returns those that pass Validate.
Combination counts by board size: Even at the maximum 21 cards, 1,330 calls to Validate (each ~8 enum comparisons) complete in well under 1 ms on any mobile CPU. No threading, caching, or bitwise trickery is required for MVP.

Implementing AnySetExists()

AnySetExists uses the same loop but short-circuits the moment it finds one valid Set. Never replace it with FindAllSets().Count > 0 — that enumerates every combination unnecessarily.
AnySetExists is called by GameSession after every single board mutation — after every refill, after every expansion, after the initial deal. Keeping it fast is not optional.

Validation Logic Flowchart


Performance Requirements

These are hard requirements — not aspirational targets. The game must meet them on the minimum supported Android device.
No allocations in the hot path. The trio array inside both loop bodies allocates. For MVP this is acceptable (the garbage collector handles it), but if profiling shows pressure, switch to a fixed-size three-element Span<Card> or pass indices rather than constructing arrays. Determinism. SetValidator must produce bit-for-bit identical results when run on the client and on the Nakama server. This means:
  • No floating-point arithmetic.
  • No DateTime, Guid, or other platform-dependent values.
  • No randomness.
  • Enum comparisons only — which are integer comparisons under the hood.

Test Cases

Write unit tests covering at minimum the following scenarios. The Expected column is the result of calling Validate(). Also test:
  • Validate with threeCards.Count == 2 throws ArgumentException.
  • AnySetExists on a board of 3 cards that form a Set returns true without visiting any second combination.
  • FindAllSets on the same 3-card board returns a list of length 1.

Implementation Checklist

1

Throws on wrong count

Validate must throw ArgumentException when threeCards.Count != 3. Do not silently return invalid — callers must know they passed bad input.
2

All four attributes checked

Verify that the implementation checks Number, Shape, Color, and Shading independently. It is easy to paste the four checks and accidentally omit one (e.g., checking Color twice and skipping Shading).
3

InvalidReason identifies the attribute

SetResult.InvalidReason must name the specific attribute that failed (e.g., "Color is mixed"), not a generic "Invalid Set". This powers the hint system and debugging.
4

FindAllSets handles all board sizes

Test with board sizes 3, 6, 9, 12, 15, 18, and 21. The triple-loop bounds must correctly produce C(n, 3) unique combinations with no duplicates.
5

AnySetExists short-circuits

Instrument Validate with a call counter in a unit test. On a board whose first valid Set is at combination index 3, AnySetExists must call Validate no more than 4 times (one fail, one fail, one fail, one success — returns).
6

No UnityEngine references

The entire SetValidator class must compile without referencing UnityEngine. Place it in an assembly definition that excludes Unity references. This ensures the same binary can run inside the Nakama server handler.
7

No heap allocations in hot path (stretch goal)

For MVP the trio array allocation per combination is acceptable. When performance profiling begins, replace it with index-based passing: pass (boardCards[i], boardCards[j], boardCards[k]) directly into an internal overload that takes three Card parameters.

Common Mistakes

Checking only three attributes instead of four. The most common bug: a copy-paste error that checks Color twice and never checks Shading (or vice versa). Write a unit test specifically for a triple that is invalid only on Shading — this will catch the omission immediately.
Replacing AnySetExists with FindAllSets().Count > 0. FindAllSets always enumerates all combinations. For a 21-card board with a Set near the end, AnySetExists may short-circuit after 50 checks instead of completing all 1,330. AnySetExists is called after every board mutation; the difference accumulates quickly.
Using reference equality on Cards. if (card == otherCard) with a plain class compares heap addresses, not attribute values. Always compare Cards using .Equals() or ensure the == operator is overloaded. If you write boardCards.Contains(targetCard) and Card lacks a proper Equals override, containment checks silently fail.
Allocating inside the inner validation loop. Creating a new[] array inside every iteration of a triple-nested loop is fine for MVP, but do not add further allocations (e.g., new List, new SetResult as a class). Keep SetResult a readonly struct so it lives on the stack.

Card Model

The Card value object and CardAttributes struct that SetValidator operates on.

Board & Dealing

How AnySetExists drives refill and expansion decisions after every board change.

Session Lifecycle

How GameSession calls Validate() on every claim and AnySetExists() after every mutation.

AI Opponents

How AIScanner uses FindAllSets() to select a Set to claim.