Skip to main content
GameSession is the central orchestrator of a match in SET: 3D Edition. Every rule, every state transition, every score change, and every end-game condition flows through it. Nothing in the domain layer modifies game state except through GameSession. Understanding its structure — the commands it accepts, the states it moves through, and the events it emits — is the foundation for building or debugging any game-mode feature.
Pre-production notice. SET: 3D Edition is in pre-production. All state machine transitions, command signatures, and event types described here reflect the current design specification. Online multiplayer’s WaitingForServer sub-state and the Nakama integration path are planned and not yet implemented.

Two Interfaces, One Class

GameSession implements two interfaces that cleanly separate the direction of data flow:
Presentation-layer ViewModels subscribe to StateStream and EventStream via R3 observables. They never call methods on GameSession directly — they only push IGameCommand objects through IMatchOrchestrator.HandleCommand. This strict separation is what makes GameSession unit-testable without any Unity dependency.

The MatchState Enum

GameSession is a finite state machine (FSM). At any moment, it is in exactly one of these states:
Four states are input-locked: ValidSetAnim, InvalidSetAnim, RefillAnim, and ExpandBoardAnim. While the session is in any of these states, HandleCommand silently discards incoming commands (logs a warning, does not throw).
WaitingForServer (online play, planned). In the online multiplayer design, Validating encompasses an inner sub-state called WaitingForServer. When the player selects a third card, the session enters Validating and the claim is dispatched to the Nakama server. The session stays in Validating / WaitingForServer — cards highlighted, input locked — until the server broadcasts its verdict. The session then transitions to ValidSetAnim or InvalidSetAnim based on the server response. There is no separate top-level enum value for this sub-state; it is tracked internally by OnlineMatchController.

State Machine Transitions

Every arrow in this diagram corresponds to a method or event that GameSession processes inside HandleCommand or its internal helpers. There are no transitions that bypass GameSession.

Commands (Input Into GameSession)

All input is modelled as command objects that implement IGameCommand. This makes the input pipeline testable: inject any command in a unit test and assert the resulting state.
AnimationCompleteCommand is generated by the Presentation layer (e.g., a Unity Coroutine or DOTween callback) and fed back into HandleCommand. GameSession does not use Time.deltaTime or WaitForSeconds internally — it is entirely event-driven.

Events Emitted (Output From GameSession)

EventStream carries discrete, named domain events that describe what happened. Presentation-layer code subscribes to specific event types for animations, toasts, and audio cues. StateStream carries a GameStateSnapshot — a complete, immutable picture of the game at the moment of every state change. ViewModels use this to re-render the entire HUD without needing to subscribe to individual events.

GameStateSnapshot

Every property is read-only. BoardSnapshot and PlayerSnapshot are also immutable DTOs. GameSession constructs a fresh snapshot after every state change and pushes it — it never reuses or mutates the previous snapshot.

GameRules Value Object

GameRules is an immutable configuration object created before StartMatch is called. It never changes during a match.
MaxBoardSize is a constant 21 — it is exposed as a property so that code consuming GameRules does not need to hard-code the magic number, but its value is always 21 and cannot be overridden. InitialBoardSize can be 12, 15, or 18 (matching the preset configurations in the data layer).

Scoring and Penalties

Scoring logic lives inside GameSession (or a private ScoringHandler helper). The rules: Score never goes below 0. Timer reaching zero from a penalty triggers an immediate MatchEnd transition, the same as the timer expiring naturally.

Input Locking During Animations

Animation states (ValidSetAnim, InvalidSetAnim, RefillAnim, ExpandBoardAnim) all require locked input. The implementation is simple: HandleCommand checks the current state first.
Commands are discarded, not queued. Queueing introduces latency, ordering bugs, and the risk of replaying a stale command after the board has changed. Players learn quickly that tapping during an animation has no effect.

Online Multiplayer (Planned)

Planned feature. Online multiplayer using Nakama is not yet implemented. The architecture described below is the target design.
In online play, Validating state has an inner sub-state: WaitingForServer. The flow:
  1. Player selects three cards → GameSession enters Validating.
  2. OnlineMatchController (Infrastructure layer) sends a claim message to the Nakama server.
  3. GameSession waits — cards remain visually highlighted but input is locked.
  4. The Nakama server validates the claim authoritatively and broadcasts a result to all clients.
  5. OnlineMatchController receives the result and injects an ApplyServerStateCommand into GameSession.HandleCommand.
  6. GameSession transitions to ValidSetAnim or InvalidSetAnim based on the server’s verdict.
GameSession itself never imports Nakama or touches a WebSocket. The entire networking path is isolated in OnlineMatchController (Infrastructure), which only communicates with GameSession via IGameCommand.

Full Match Lifecycle Walkthrough

The following table traces a complete single-player match from start to finish:

Implementation Checklist

1

HandleCommand is O(1) — no blocking

Every branch of HandleCommand must complete in constant time. Never call Thread.Sleep, await, or any synchronous I/O inside HandleCommand. The session must remain responsive at all times.
2

Input discarded during locked states

Commands received during ValidSetAnim, InvalidSetAnim, RefillAnim, ExpandBoardAnim, or MatchEnd must be silently discarded. Log a warning. Do not throw, do not queue.
3

AnySetExists called after every board mutation

After every call to Board.PlaceCard, Board.RemoveCard, or Board.Expand, GameSession must call ISetValidator.AnySetExists(). Missing even one call allows the game to get stuck.
4

End-game condition checked after every state transition

After every state change, verify the end-game conditions: deck empty + no Set, board at 21 + no Set, or timer ≤ 0. Any one of these must immediately transition to MatchEnd and emit MatchEndedEvent.
5

StateStream emits on every state change

Every state transition — including selecting individual cards — must push a new GameStateSnapshot to StateStream. ViewModels depend on every push to re-render correctly.
6

EventStream emits distinct domain events

EventStream must emit SetClaimedEvent, BoardRefilledEvent, ScoreChangedEvent, etc. as discrete events — not bundled with the snapshot. Animation and audio systems subscribe to specific event types.
7

No UnityEngine or Nakama references

The GameSession class must compile in a pure .NET context. Add an assembly definition with a reference whitelist that excludes UnityEngine and Nakama assemblies.
8

Fully unit-testable with mocked dependencies

GameSession’s constructor accepts ISetValidator and IAIScanner. In tests, inject mock implementations. Write tests that send a sequence of SelectCardCommand objects and assert the resulting state and emitted events.

Common Mistakes

Allowing input during animation states. The most common bug in early implementations: a player taps quickly and a second claim starts before the first animation finishes, corrupting board state. Always check IsLockedState at the top of HandleCommand before any other logic.
Forgetting AnySetExists after every board change. It is easy to remember the check after refill but forget it after expansion, or after the initial deal. Centralise the “mutate board → check AnySetExists → possibly expand or end match” logic into a single private method (ProcessBoardAfterMutation) and call only that method from every place that changes the board.
Emitting mutable objects on StateStream. If GameStateSnapshot contains any mutable reference type (e.g., a List<int> that the session later modifies), all subscribers will see the mutation retroactively. Every object pushed to StateStream must be fully immutable at the moment of emission. Use IReadOnlyList, immutable arrays, or structs for all snapshot fields.
Calling GameSession methods from multiple threads. Nakama network callbacks arrive on a background thread. Never call HandleCommand from a background thread. Use a MainThreadDispatcher (or Unity’s UnitySynchronizationContext) to marshal all incoming server messages to the main thread before injecting them into GameSession.

Card Model

The Card and CardAttributes types that GameSession manipulates through Board and Deck.

Set Validation

ISetValidator — the domain service GameSession calls on every claim and board mutation.

Board & Dealing

How Board manages card slots, and how GameSession drives refill and expansion.

AI Opponents

How IAIScanner integrates with GameSession’s Update loop and command pipeline.