Reference document for rebuilding the Spellsource game client in Three.js/React. Describes the architecture, rendering strategy, and game flow of the Unity client in spellsource-client/src/unity/Assets/Scripts/.
The Unity client uses a reactive pipeline built on UniRx (Reactive Extensions for Unity):
Server (gRPC stream)
→ DefaultClient (network singleton)
→ GameController (central orchestrator)
→ MessageHandler[] (animation delay pipeline)
→ Reactive properties (entities, gameState, progress, actions)
→ Views subscribe & render
Player input flows in reverse:
Pointer events → CommandController → GameController → DefaultClient → Server
The key architectural insight: messages from the server arrive instantly, but the client needs to animate them sequentially. The MessageHandler pipeline inserts coroutine-based delays between message processing and reactive property updates, so views animate in order.
DefaultClient (HiddenSwitch/DefaultClient.cs)
Singleton managing the gRPC connection. Provides:
GameMessages()— Observable stream ofServerToClientMessage(raw, no animation delay)SendGameMessage()— SendClientToServerMessageto server- Token-based JWT auth, stored to disk for session persistence
- Automatic reconnection logic
Central orchestrator. The most important class. Singleton accessed via GameController.instance.
Reactive properties (delayed by animations):
| Property | Type | Description |
|---|---|---|
entities |
ReactiveDictionary<int, Entity> |
All game entities by ID |
gameState |
ReactiveProperty<GameState> |
Latest game state |
progress |
ReactiveProperty<GameProgress> |
Phase/turn state machine |
request |
ReactiveProperty<ServerToClientMessage> |
Latest action/mulligan request |
actions |
ReactiveDictionary<int, SpellAction> |
Available actions by source entity ID |
gameOver |
ReactiveProperty<GameOver> |
Win/loss result (NOT delayed) |
timeLeft |
Observable<long?> |
Turn timer countdown |
powerHistory |
ReactiveCollection<GameEvent> |
Event log for history panel |
Key detail: actions is intentionally NOT delayed by animations. This lets the player queue actions faster than animations resolve. The action dictionary is keyed by source entity ID, mapping to the SpellAction for that entity.
Message processing pipeline:
DefaultClient.GameMessages()emits raw messages- Filter to relevant types (ON_UPDATE, ON_GAME_EVENT, ON_REQUEST_ACTION, ON_MULLIGAN, ON_GAME_END)
- Each message passes through
MessageHandler[]components (attached to same GameObject) - Handlers yield coroutine delays for animations
- Delayed message emits to
m_Messagessubject - Reactive properties update, views react
Entity zone management: When a message arrives, the controller:
- Updates the
entitiesdictionary with all entities fromGameState.Entities - Groups changed entity IDs by
LocalZoneKey(zone type + is-local-player) - Runs
DiffSequenceagainst each zone's current entity list to produce add/move/remove operations - Zones animate the transitions
Abstract base class. Each subclass processes a specific event type and returns a coroutine delay.
abstract IEnumerator HandleMessage(ServerToClientMessage message);The GameController runs all handlers for each message via ProcessMessage(), concatenating their delays. This serializes animations so they play in order.
| Handler | Trigger | Animation |
|---|---|---|
DrawCardHandler |
DRAW_CARD event | Deck draw animation trigger, ~0.3s delay |
PhysicalAttackHandler |
PHYSICAL_ATTACK event | DOTween punch attacker toward defender, shake defender. ~0.5s |
DamageHandler |
DAMAGE event | Floating damage number (pooled ValueEventView). ~0.15s |
HealingHandler |
HEAL event | Floating heal number. ~0.15s |
KillHandler |
KILL event | Death particle effect at entity position. ~0.2s |
AftermathHandler |
KILL event with deathrattle | Extra delay for aftermath resolution. ~0.25s |
MissileFiredHandler |
MISSILE_FIRED event | Parabolic projectile from source to target with hit particles. ~0.4s |
BoardChangeAndSummonHandler |
SUMMON event | Summon particle effect. ~0.25s |
OpponentPlayedCardHandler |
PLAY_CARD event (opponent) | Briefly shows opponent's card in center. ~1.5s |
CardsReceivedHandler |
Card added to hand | Layout update delay. ~0.15s |
DefaultDelayHandler |
Any opponent message | Generic delay for opponent events. ~0.15s |
Expensive visual effects use ObjectPool<T>:
OpponentPlayedCardHandlerpools card display objectsValueEventHandlerpools floating damage/heal numbersMissileFiredHandlerpools projectile objects
Pools preload configurable counts and rent/return instances.
The primary visual component for any game entity. Uses Unity's serialized field binding pattern where each visual element is a configurable slot:
Visual slots:
- Images: Primary/Secondary/Highlight/Shadow fills (colored per hero class), portrait sprite, sprite shadow
- Text values: Attack, Health, Armor, ManaCost, Mana, MaxMana, Name, Description, Tribe, Fires (quest counter), Spellpower
- Card type indicators: Toggle visibility for Minion/Spell/Weapon/Champion/Skill frames
- Status icons: Guard (taunt), Dodge (divine shield), Deflect, Aftermath (deathrattle), Hidden (stealth), Dash (rush), Blitz (charge), Double Strike (windfury), Toxic (poisonous), Lifedrain (lifesteal), Exhausted (summoning sickness), Stunned (frozen), Silenced, Wounded (enraged), Trigger host
- Rarity badges: Uncollectible, Free, Common, Rare, Epic, Legendary, Alliance
- Outlines: Attack/Health/Cost outlines colored green (buffed) or red (debuffed) based on comparing current vs base values
- Counters: Charge counter display
Data flow: The data reactive property holds the current Entity. When it changes, SetDirty() runs and updates all visual slots in a single pass. The entity's Art field provides hero-class-specific coloring.
Sprite resolution: If the entity has no sprite, falls back to MissingAssetsProfile arrays (per entity type) using a hash-based index for deterministic assignment.
Multiple prefab variants exist per context:
Small Card.prefab— default compact viewSmall Card - Hand.prefab— in-hand variantSmall Card - Inspector.prefab— hover tooltip variantLarge Card.prefab— expanded detail viewChampion.prefab— hero portraitMinion.prefab/Minion - Local.prefab— battlefield unitSecret.prefab,Quest.prefab— special zone displays
Base class for any MonoBehaviour that holds an Entity reference. Provides the reactive data property. EntityModelView extends this.
Abstract base for entity containers (hand, battlefield, hero, deck, etc.). Implements IEntityZone which provides:
entities— current ordered list of entities in this zoneKey—LocalZoneKey(zone type + is-local-player flag)- Add/Move/Remove operations that trigger layout updates
Identifies zones: combines ZonesMessage.Zones enum with a boolean isLocal flag. Two heroes exist (local + opponent), two hands, two battlefields, etc.
Collects all zone instances in the hierarchy via a lookup controller pattern. The GameController iterates all zones to dispatch entity changes.
LCS-based diff algorithm that compares old and new entity lists for a zone and emits:
- Add: Entity appeared in zone (play from hand, summon, draw)
- Move: Entity repositioned within zone (board rearrangement)
- Remove: Entity left zone (died, returned to hand, played)
This drives the animated layout transitions.
Custom layout system that replaces Unity's built-in LayoutGroup. Arranges children along configurable U/V axes with:
- DOTween-animated position transitions when entities move
- Overflow culling (excess cards in hand are hidden)
- Support for both RectTransform (2D UI) and Transform (3D) children
- Configurable spacing, padding, alignment
When entities are added/moved/removed, the layout smoothly animates all children to their new positions.
Handles all player interaction with entities. State machine:
Unactivated → (click/drag) → Activated → (valid target) → WillIssue → (release) → Issued
↓
(cancel/invalid) → Unactivated
Activation logic:
- Minimum hold time (0.18s) prevents accidental clicks
- Drag distance threshold determines targeting vs clicking
LineControllerdraws a targeting line from source to cursor during drag- Cancel zone (dragging back to source area) deactivates
Enable conditions: CommandControllerEnableCondition subclasses gate whether an entity can be interacted with:
PlayableCondition— entity has available actions inGameController.actionsCanMulliganCondition— mulligan phase is active
When a command is issued, the controller needs to find the right action index:
- Untargeted plays: Find actions for the source entity with no targets (e.g., play a spell without targets)
- Targeted plays: Find actions for the source entity that target a specific entity
- End turn: Find the END_TURN action type
The Unity client stores actions as ReactiveDictionary<int, SpellAction> keyed by source entity ID. Each SpellAction contains:
action— the action index to send to serveractionType— END_TURN, PHYSICAL_ATTACK, SPELL, SUMMON, etc.sourceId— which entity is actingtargetKeyToActions— list of (target entity ID → action index) pairschoices— nested sub-actions for discover/choose-one
Simple controller that:
- Observes
GameController.progressfor mulligan states - Displays starting cards in the hand zone
- Tracks which cards the player toggles for discard
- On confirm, calls
GameController.EndWithMulligan(discardedIndices)
Auto-mulligan option exists (controlled by m_AutoMulligan flag on GameController) that immediately accepts the starting hand.
Derives the current game phase from message data:
| State | Condition |
|---|---|
Connecting |
No messages received yet |
InProgressBothMulligan |
ON_MULLIGAN message received, both players mulliganing |
InProgressLocalMulligan |
Local player still mulliganing |
InProgressOpposingMulligan |
Opponent still mulliganing |
LocalTurn |
Game state says it's local player's turn |
OpposingTurn |
Game state says it's opponent's turn |
GameOverLocalWin |
GameOver received, local player won |
GameOverOpposingWin |
GameOver received, local player lost |
Replaying |
Replay mode |
Also tracks an Actions sub-state:
Uninterruptible— special actions required (discover, battlecry targeting)Interruptible— normal turn, can end at any time
The main Game.unity scene uses a Canvas-based 2D UI layout:
Root Canvas (1920x1080)
├── Opponent Area
│ ├── Opponent Hero (Champion.prefab)
│ ├── Opponent Hero Power
│ ├── Opponent Weapon
│ ├── Opponent Hand (AnimatedFlowLayout, cards face-down)
│ ├── Opponent Battlefield (AnimatedFlowLayout, Minion.prefab instances)
│ ├── Opponent Secrets/Quests
│ └── Opponent Deck Pile
├── Player Area
│ ├── Player Hero (Champion.prefab)
│ ├── Player Hero Power
│ ├── Player Weapon
│ ├── Player Hand (AnimatedFlowLayout, Small Card - Hand.prefab instances)
│ ├── Player Battlefield (AnimatedFlowLayout, Minion - Local.prefab instances)
│ ├── Player Secrets/Quests
│ └── Player Deck Pile
├── Mana Tray (Mana Available/Used/Locked prefabs)
├── End Turn Button
├── Power History Panel (scrollable event log)
├── Timer Display
├── Card Inspector (hover tooltip, Large Card.prefab)
├── Opponent Played Card Display (center of screen, pooled)
└── Effect Overlays (damage numbers, particles, missiles)
Effects are prefab-based, instantiated at entity positions:
Damage.prefab— damage impactDeath.prefab— kill particlesHealing.prefab— healing glowSummoning.prefab— summon flashPhysical Attack.prefab— attack impactMovement.prefab— movement trailTrigger Fired.prefab— trigger activation
Spell projectiles (MissileView.cs):
- Parabolic arc from source to target
- Cast effect at source
- Hit effect at target
- Configurable prefab per spell (e.g.,
Blue Fireball Missile.prefab)
Persistent visual indicators on entities:
Guard.prefab— taunt shield overlayTrigger.prefab— trigger host indicatorDiscarded.prefab— discard markerGeneral.prefab— generic status overlay
When hovering over an entity:
HoverControllerdetects pointer enter with debounceInspectOnHoverControlleractivates inspectionInspectableCardViewrenders a full-detail card near the cursor- Position adapts to screen edges (left/right/top)
FlyInViewanimates the card sliding in- Deactivates on pointer exit or when command activation begins
The inspection card appears in a zone that doesn't overlap the source:
- Top, Left, or OppositeFinger placement
- Configurable padding from screen edges
- Different strategies for mobile (finger occlusion) vs desktop
The most important pattern to replicate. Messages must be processed sequentially with delays:
message arrives → compute animation duration → wait → update state → render
In React/Three.js, this could be:
- A message queue with
async/awaitprocessing useReducerfor state, with an animation queue that gates when the reducer runs- Or a simpler approach: process messages immediately into state, but queue animations separately
The ReactiveDictionary<int, Entity> pattern is central. Every view subscribes to entity changes by ID. In React, this maps to:
- A
Map<number, Entity>in state - Components select their entity by ID and re-render when it changes
Entities are grouped by zone. When entities move between zones, a diff algorithm determines the minimal set of add/move/remove operations. In Three.js:
- Group entities by zone in state
- Use React keys for entity components
- Animate position transitions with
@react-springor manual lerping
Actions are stored as a map from source entity ID to SpellAction. When a player clicks an entity:
- Look up actions for that entity
- If untargeted actions exist, play them immediately
- If targeted actions exist, enter targeting mode (show valid targets)
- If no actions, the entity is not playable
Different prefabs render the same entity data depending on context (hand card vs battlefield minion vs hero). In React:
- Different components for hand cards, battlefield minions, heroes
- All receive the same
Entitydata - Visual differences are purely component-level
Stats are colored based on comparison with base values:
attack > baseAttack→ green (buffed)hp < maxHp→ red (damaged)manaCost < baseManaCost→ green (cost reduced)manaCost > baseManaCost→ red (cost increased)