Card. Understanding how Card is modeled, why it is immutable, and how its compact CardId encoding works is the prerequisite for every other system in this documentation.
Pre-production notice. SET: 3D Edition is in pre-production. All class shapes, encoding formulas, and invariants described here reflect the current design specification. Signatures may shift before the first playable build.
Why the Card Is a Value Object
In object-oriented design, an entity has identity that exists independently of its data — twoPlayer objects can have the same score yet be different players. A value object is the opposite: its identity is its data. Two values with the same fields are, by definition, the same thing.
Card is a value object. A card with Number.Two, Shape.Oval, Color.Green, Shading.Striped is that card — universally, unambiguously. There is no concept of “the original” versus “a copy.” This has three practical consequences for your implementation:
- No mutable state. Once constructed, a
Cardnever changes. Any operation that needs a “different card” creates a new object; it does not modify an existing one. - Free sharing. The same
Cardinstance can be referenced simultaneously by theDeck, theBoard, aPlayer’s collected-sets list, and theSetValidatorwith no risk of accidental mutation. - Value equality.
card1.Equals(card2)must be true when their attributes match — not when they share the same heap address.
The Attribute Enums and CardAttributes Struct
The four attributes are each defined as a C#enum. The underlying integer values are intentional: they feed the CardId encoding formula described in the next section.
CardAttributes is a readonly struct — the readonly modifier prevents any field from being reassigned after construction, even inside the struct itself. This is the correct choice for a four-field bundle that is copied frequently and never mutated.
The Card Class
sealed (no subclassing) and exposes only get-only properties. There are no setters, no internal mutation methods, and no static factory beyond the constructor. Equality delegates to Id, which is valid because every Id maps to exactly one attribute combination.
The 81-Card Universe
The deck contains exactly 81 unique cards — one for every combination of 3 values × 4 attributes (3⁴ = 81). Cards are never created at runtime during a match. They are generated once at deck-initialisation time, placed into aQueue<Card>, and then consumed (drawn) or placed (dealt) without ever being constructed again.
Every possible row from this table exists exactly once in the deck. The
Deck constructor loops through all combinations in a deterministic order, constructs 81 Card objects, and shuffles them with a seeded System.Random. After that, the 81 instances are fixed for the lifetime of the match.
CardId Encoding Formula
CardId is an int in the range 0–80 computed by the following formula:
Number uses 1-based values (One = 1, Two = 2, Three = 3), the formula subtracts 1 to produce a 0-based contribution. The other three enums are already 0-based (Diamond = 0, Red = 0, Solid = 0).
Example — the card Two Squiggles Green Striped:
To reconstruct attributes from an Id (useful for serialisation and debugging):
byte), deterministic, and enables fast bitwise operations in SetValidator — specifically, the all-same / all-different check can be implemented with integer arithmetic rather than branching enum comparisons, which keeps validation well under 10 µs per call.
Related Domain Objects
Card does not exist in isolation. Three other types form the immediate context:
CardSlot
CardSlot.Index is a board position index (0–20). Do not confuse it with Card.Id (an attribute encoding). A card with Id = 40 can occupy any slot; the slot index is purely a layout concern.
Deck
Deck constructor generates all 81 Card instances, applies a Fisher–Yates shuffle using a provided seed (0 = non-deterministic), and stores them in draw order. No card is ever added back to the deck.
Board
Board stores CardSlot values, not raw Card references, so that empty positions are first-class. GameSession orchestrates all transfers: draw from Deck, place into Board, remove from Board after a valid Set.
Implementation Checklist
1
Sealed class with IEquatable
Card must be sealed and implement IEquatable<Card>. Override both Equals(Card?) and Equals(object?), and override GetHashCode() to return Id.GetHashCode().2
Value equality by Id
card1.Equals(card2) returns true when both have the same Id. Because every Id maps to a unique attribute combination, this is equivalent to comparing all four attributes individually.3
No mutable state
All properties must be
get-only. There must be no set, init, or any method that modifies a field after construction. Use readonly backing fields.4
CardId encoding formula applied correctly
Verify that
(Number - 1) * 27 + Shape * 9 + Color * 3 + Shading produces a value in [0, 80] for every valid attribute combination. Write a unit test that round-trips all 81 Ids through FromId() and confirms the attributes match.5
Build-time test: all 81 cards present
Write a unit test (or editor test) that generates all 81
Card objects, stores them in a HashSet<Card>, and asserts Count == 81. If any duplicate slips through — either from a wrong Id formula or from a broken Equals — the set count will be less than 81.Common Mistakes
Related Pages
Set Validation
How SetValidator uses CardAttributes to enforce the all-same / all-different rule.
Board & Dealing
How Board manages CardSlots, refills empty positions, and expands when no Set exists.
Session Lifecycle
How GameSession orchestrates the Deck, Board, and Cards through the full match state machine.
AI Opponents
How AIScanner scans the Board’s Cards to find and claim Sets.