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()
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.
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. TheExpected column is the result of calling Validate().
Also test:
ValidatewiththreeCards.Count == 2throwsArgumentException.AnySetExistson a board of 3 cards that form a Set returnstruewithout visiting any second combination.FindAllSetson 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
Related Pages
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.