Feb. 28 Changelog

This last week has been a massive improvement over our first release, with a lot of major updates and changes to the UI and finding some of the small fringe bugs. I understand that when you see a weeks worth of changes in a wall of text like this, it looks like things weren’t ready for the stress testing. But the truth is just the opposite! It’s because of the stress testing that this was even possible to find. The build will go out tomorrow, meaning you still have an opportunity to get in on the beta test by signing up at rankbreakr.com

Raven Hand — Profile Zoom Panel & Swap Button

ProfileZoomPanel Opponent Raven Hand Not Displaying

Root cause: ZoomPanelRavenHand was activated when the ProfileZoomPanel opened, at which point PlayerHandManager.GetHand() returned the current live hand state — cards already removed that the main RavenHand still showed as pending. The two controllers were activating at different times and falling out of sync.

Fix: Both the main RavenHand and ZoomPanelRavenHand now activate at the same moment inside RavenEffect.OnReveal(). Both controllers subscribe to the same PlayerHandManager events simultaneously, receive all add/remove events in lock-step, and maintain identical state including pending removals.

ProfileZoom RavenHand Showing Without Raven Being Played

Root cause: The ProfileZoom RavenHand was activating unconditionally whenever the opponent profile opened, regardless of whether Raven had been played. Players could see opponent cards without earning that view.

Fix: The ProfileZoom RavenHand is only activated from RavenEffect.OnReveal(). If Raven has not been played, the controller is never activated and no cards are shown.

RavenHandSwapButton Not Receiving Clicks

Root cause: The button script called gameObject.SetActive(false) inside Awake(), which prevented Update() from ever running. A CanvasGroup added programmatically introduced additional interactability flags that conflicted with normal button behavior.

Fix: All visibility logic was removed from the script. The button only handles swap/reset logic. Visibility is controlled externally — RavenEffect.OnReveal() calls SetActive(true) on the button when Raven is played. The button starts inactive in the scene by default.

RavenHandSwapButton — UI Rotation Blocking Clicks

Root cause: Unity’s GraphicRaycaster only hits front-facing UI elements. Rotating a UI GameObject on Y or Z in 3D space causes it to face away from the camera, making it unclickable even when visible.

Fix: Keep rotation at (0, 0, 0). For visual flips or mirroring, use negative Scale values (e.g. Scale: (-1, 1, 1)) — this preserves raycast hit detection.

Orange Overlay Showing in ProfileZoomPanel RavenHand

Root cause: RavenHandController had a single code path in ApplyVisualSettings that always called ShowRavenHandOverlay() on every spawned card. The zoom panel shared this logic even though the overlay serves no purpose in an informational view.

Fix: Added a showRavenHandOverlay serialized bool to RavenHandController (default true). The overlay call is now gated behind that flag. The ZoomPanelRavenHand GameObject has the flag unchecked, so its cards always spawn without the overlay.

RavenHandSwapButton — No Visual Distinction When Swapped to Front

Root cause: When the RavenHand swapped to the front of the hierarchy, the player hand remained visible behind it with no clear signal that the opponent’s hand was now being shown.

Fix: On swap, RavenHandSwapButton calls playerHandGameObject.SetActive(false), hiding the player hand so the RavenHand occupies the space cleanly. Pressing the button again restores the player hand and resets all positions to defaults.

Tiber Sax — State Persistence & Lane Transfer

Tiber Sax Restriction Persisting Between Matches

Root cause: GameManager.Initialize() guarded CleanupPreviousState() behind if (_matchContext != null). When a new GameManager was instantiated after returning from the main menu, _matchContext started null and the cleanup chain never ran. TiberSaxEffect is a ScriptableObject whose locationRestrictions and activeTiberSaxCards dictionaries survive scene changes, carrying the previous match’s lane restriction forward. GameManager.OnDestroy() also called LocationAbilityStateCleaner.ResetAllLocationAbilityStates() for location abilities but never called ClearCardAbilityState() for card-level ScriptableObject effects.

Fix: Removed the _matchContext != null guard so CleanupPreviousState() and the full ICleanableEffect auto-discovery pass run unconditionally on every match initialization. Added ClearCardAbilityState() to OnDestroy() alongside existing location ability cleanup so ScriptableObject state is fully purged on scene teardown — both the scene-reload and same-session rematch paths are covered.

Tiber Sax Lane Transfer Not Updating Restrictions Mid-Match

Root cause: TiberSaxEffect subscribed to GameEvents.OnCardMoved in OnEnable() to detect lane moves. GameEvents.ClearAllEvents() — called during CleanupPreviousState() — sets OnCardMoved = null, wiping the subscription. Because TiberSaxEffect is a ScriptableObject already in memory, OnEnable() never re-fires. After the first match cleanup, any lane move of Tiber Sax in a subsequent match was silently dropped.

Fix: Added a public HandleCardMoved() method to TiberSaxEffect that feeds directly into UpdateTiberSaxLocation(). Wired it into AbilityManager.OnCardMovedFromLane() via ResourceLoader.Load<TiberSaxEffect>(), following the same pattern used for HekaEffect. Every card move goes through AbilityManager, so the restriction transfer is guaranteed regardless of GameEvents subscription state — old lane is cleared, new lane is restricted, and activeTiberSaxCards is updated atomically.

Punishing Mode — Visual & Tracking Fixes

Opponent Cards Showing as Destroyed in Punishing Mode

Root cause: CardDisplay.CheckAndApplyCardDamage() called CardTextureManager.SetCard() unconditionally. PermanentCardDamageTracker matches cards by UUID, so any card on the board whose UUID existed in the local player’s damaged set received the torn material regardless of ownership.

Fix: Added a guard in CheckAndApplyCardDamage: if runtimeCardData is present and PlayerID.IsLocalPlayer is false, the damage visual is skipped. Cards with no runtimeCardData (collection panels) still apply the check as before since they are always local-player cards.

Restoration Missions Not Tracking Progress

Root cause: Event-based tracker ScriptableObjects only subscribed to game events when MissionManager.AddMissionToActive() called tracker.Subscribe(). If a restoration mission used a tracker type no active normal mission also used, Subscribe was never called and UpdateMissionProgress was never invoked. The tracker lookup also used reference equality, which could fail if restoration missions referenced different ScriptableObject instances of the same tracker type.

Fix: CardRestorationMissionManager now owns direct subscriptions to CardPileManager, BoardManager, and PlayerHandManager events via TrySetupMatchTracking(). Called on sceneLoaded and whenever the first mission is assigned, tears down cleanly on scene unload. The UpdateMissionProgress lookup was changed to compare by GetType().Name instead of reference. Redundant push calls were removed from the six event-based tracker classes to prevent double-counting.

72-Hour Card Lockout Not Auto-Restoring Cards

Root cause: PermanentCardDamageTracker stored damaged card UUIDs in a HashSet<string> with no timestamps, so cards remained locked indefinitely.

Fix: Storage changed to Dictionary<string, long> mapping each UUID to its damage unix timestamp. AutoRestoreExpiredCards() runs immediately on load (catching cards that expired between sessions) and every 5 minutes during play. Any card reaching 72 hours is automatically repaired and its pending restoration mission is cleaned up via CardRestorationMissionManager.RemoveMissionForCard(). Save format updated to include timestamps with a full backward-compatibility migration path.

Direct Challenge — Rank Point Steal Fix

Opponent Losing 0 RP on Loss

Root cause (client): CheckAndRecordPassiveMatchAsync re-checked the opponent’s session status at match-end. The session cache expires after 5 seconds, so a fresh cloud query always ran by the time a match finished. If the opponent came back online at any point during the match, the re-check returned isActive = true and the passive tracking block was silently skipped.

Fix (client): SetSnapshotChallengeMatch now accepts an opponentWasPassive flag. SnapshotChallengeService.PrepareSnapshotChallengeAsync passes opponentWasPassive: true since direct challenges are only offered against offline players. When set, CheckAndRecordPassiveMatchAsync skips the post-match re-check and always records the deduction — session state at challenge time governs the transfer, not match-end state.

Root cause (cloud): guidToAuthMapping was declared with const inside an if block in recordPassiveMatchParticipation, putting it out of scope in the rival-adding section. This threw a ReferenceError that was silently caught, so the passive player’s rival list was never updated after a loss.

Fix (cloud): guidToAuthMapping moved to let in the outer scope. Redeploy recordPassiveMatchParticipation with the corrected version in the Passive Matchmaking — Cloud Code Endpoints page.

Card Ability & Placement Fixes

Sirivex — Stolen Card Failing to Add to Opponent Hand

Root cause: RuntimeCardData generates a deterministic InstanceID from “{playerId}_{cardUUID}”. When Sirivex (played by the opponent) stole a card it already held, the new copy produced an identical InstanceID. PlayerHandManager.AddCard detected the collision as a duplicate and silently rejected it.

Fix: Sirivex now creates stolen cards with forceUniqueID, which prefixes a monotonic spawn counter to the ID — the same scheme used for JunkCards.

Lucan Merric — Orrin Brennic Not Receiving Buff

Root cause: LucanMerricEffect is a ScriptableObject. The powerManager instance field was only assigned when null, so if Lucan was played in match A, the field still pointed to match A’s dead CardPowerManager when match B started. Lucan’s buffs in match B were written into the defunct manager from match A, which the current match’s scoring system never reads.

Fix: InitializeManagers now always updates powerManager and boardManager from the incoming context when the context provides non-null values. CleanupState also explicitly nulls those fields at match end.

Stonewhal / Summoned Cards — RockBlock Displacing to Slot 0

Root cause: After summoning the RockBlock, StonewhalEffect called RepackLane, which sorts cards by PlayOrderIndex. Summoned cards carry PlayOrderIndex = -1, which sorts before every hand-played card and moved the RockBlock to slot 0.

Fix: Removed the RepackLane call from StonewhalEffect — the card was already placed at the first available slot. The global RepackLaneAnimated sort now maps PlayOrderIndex = -1 to int.MaxValue, so summoned cards sort to the end of the packed sequence.

Nyxion Resurrection — Resurrecting into Wrong Slot

Root cause: FindRandomAvailableSlot calculated the target slot as playerCardsInLane (a raw count), which is only correct when cards fill slots contiguously from 0. With any gap — such as after Bloodthirst destroys a card — the count-based heuristic targeted an occupied slot.

Fix: Replaced with boardManager.FindFirstAvailableSlot, which iterates slots 0→3 and returns the first empty one — the same gap-aware method used everywhere else in the system.

Ghost Slot / Card Returned to Hand After Failed Placement

Four bugs combined to produce this:

Veylan Chain Runner — Rook InstanceID collision: Veylan adds a Rook to hand using the standard constructor, generating “LocalPlayer_{RookUUID}”. If a Rook from a previous Veylan reveal was already on the board, both shared the same InstanceID, causing RepackCards to resolve the wrong placement and slot indices to drift. Fixed with forceUniqueID.

DOM vs BoardManager slot selection diverge: The drop zone picked card slots using DOM childCount, while BoardManager.PlaceCard checked its internal placements dictionary. Any prior desync caused placement to be rejected and the card returned to hand. Fixed by using boardManager.FindFirstAvailableSlot as the authoritative selector.

Energy not refunded on placement failure: Energy was spent before BoardManager.PlaceCard was called. If placement failed, the revert path returned the card to hand but never refunded the energy. Fixed by adding RefundEnergy to the revert path.

OnCardPlayed firing before placement confirmed: AbilityManager.OnCardPlayed (which updates DisabledAbilityTracker) fired before BoardManager.PlaceCard returned a result. If placement failed, ability state was permanently mutated for a card that never landed. Moved to fire only inside the confirmed success path.

Convergence — Location & Scene Teardown

Other Locations Never Rotating Back to Normal

Root cause: LocationContainerAnimator had two paths to rotate non-Convergence locations back to 0°. The first — OnPlayerRevealComplete — was dead code; GameEvents.TriggerPlayerRevealComplete was never called anywhere in the project. The only live path was OnConvergenceCleared, which fires in ConvergenceLocationRestriction.OnRoundStart. If that event chain broke, lastKnownConvergenceLane stayed set and locations remained at 90°.

Fix: The animator now stores lastKnownConvergenceRound alongside lastKnownConvergenceLane when OnConvergenceSet fires, and subscribes directly to GameEvents.OnRoundStart as a safety net. If round > lastKnownConvergenceRound and the lane is still marked, it rotates back and clears state. If OnConvergenceCleared fires first (the normal path), the OnRoundStart handler sees nothing and does nothing. The dead OnPlayerRevealComplete handler was removed.

Convergence — Rotation and Restriction Firing When Entered via Cyran

Root cause: When a card ability (e.g. Cyran) replaced a location mid-round, LocationManager.ReplaceLocation called TriggerLocationAbility with the same OnReveal trigger as a natural end-of-round reveal. ConvergenceAbility had no way to distinguish the two cases and would set the restriction and rotate other locations even though Convergence entered mid-round already-revealed.

Fix: Added IsReplacementReveal (default false) to AbilityContext. Both ReplaceLocation and ReplaceLocationWithSpecific now pass isReplacementReveal: true when calling TriggerLocationAbility. ConvergenceAbility early-returns when the flag is set, skipping both the rotation and the restriction.

Convergence — Opponent Playing Cards in Restricted Lanes

Root cause: OpponentAI.EvaluateBestLane correctly filtered lanes through CanPlayCardAtLocation, so bestLane was restriction-aware. Two pattern-learning calls applied after — AdjustLaneBasedOnOpponentPatterns and BreakPredictablePattern — could return any lane index to make the opponent less predictable, and their result was written back to bestLane with no awareness of active restrictions.

Fix: Both pattern-learning overrides now validate the adjusted lane through CanPlayCardAtLocation before accepting it. If the suggested lane fails the check, the adjustment is ignored and the restriction-aware bestLane is kept.

TheFurnace / Lucan Merric — Warning Spam on Scene Teardown

Root cause: GameManager.OnDestroy called cleanup methods that ultimately called CardEffectUtilities.FindCardDisplayByInstanceID, which does a FindFirstObjectByType<LocationManager>(). By the time OnDestroy fires during scene unload, LocationManager may already be destroyed — causing the lookup to return null and log a warning for every affected card.

Fix: ClearLocationAbilityState() is now called from HandleEndGame() after the match result is calculated and before TriggerMatchEnded fires — while the scene is fully alive. Duplicate calls were removed from OnDestroy, which now only handles event unsubscription and mulligan reset. The LogWarning for a null LocationManager was downgraded to Debug.Log.

Biography Panel & Scrollable Text

ScrollRect — Text Not Scrolling, Bouncing Back

Three compounding issues prevented biography text from scrolling.

Root cause 1 — Viewport not filling the panel: The Viewport RectTransform had fixed center anchors instead of stretching to fill BiographyPanel.

Fix: Set Viewport anchors to full Stretch/Stretch.

Root cause 2 — Circular dependency between Content and BiographyText: Content had a ContentSizeFitter (verticalFit: PreferredSize) to auto-size to the text. BiographyText was set to full vertical stretch, meaning its height derived from Content’s height. Neither could settle on a real value — Content stayed at height 0 and the ScrollRect had nothing to scroll.

Fix: Changed BiographyText anchors to Top + horizontal stretch only. TMP wraps the text, reports a real preferredHeight, and ContentSizeFitter resizes Content correctly.

Root cause 3 — No Layout Group on Content: Without a Layout Group, ContentSizeFitter had no mechanism to aggregate child sizes, so Content remained height 0 even after the anchor fix.

Fix: Added a VerticalLayoutGroup (childForceExpandHeight: false) to Content so preferred height is aggregated correctly.

Biography Field — Card Data & Display Pipeline

Root cause: No biography data field existed on any card type and no display path in CardDetailPanelController or CardDetailUIManager populated the BiographyText TMP object.

Fix: Added a biography serialized field to BaseCard, DisruptorCard, and IntellectualPropertyCard. Variant cards and all four Legendary types (M1–M4) expose a computed Biography property that delegates to their respective base card, ensuring group-level biography inheritance by design. Every display entry point in the panel controller now calls UpdateBiographyText.

Unified Ability State Management

Cross-Match Ability State Leakage — Structural Fix

Three separate patterns were managing how card effects and location abilities receive events and clean up between matches, each with a different failure mode.

Root cause 1 — Hard-wired per-effect calls in AbilityManager.OnCardMovedFromLane(): Every card needing to react to a card move (Tiber Sax, Strayline, Axl, Riff, Heka) required a manual if-block in AbilityManager. Adding a new card meant editing the manager. Missing a call meant the effect silently never fired.

Root cause 2 — GameEvents.OnCardMoved and GameEvents.OnMatchStarted self-subscriptions on ScriptableObjects: Effects like LucanMerricEffect, VarisEffect, DaishoReiEffect, and TheFurnaceAbility subscribed to GameEvents in OnEnable. Between matches, GameEvents.ClearAllEvents() wipes every subscription. ScriptableObjects do not re-trigger OnEnable between matches, so after match 1 these effects were permanently deaf to card-move events and the OnMatchStarted cleanup that was supposed to prevent cross-match state leakage was never running.

Root cause 3 — FindFirstObjectByType calls scattered across LocationManager.ClearLocationRestrictions(): Every location restriction MonoBehaviour required a manual block in the manager. Adding a new restriction meant editing the manager and risking stale lookups on scene teardown.

Fix: Two minimal interfaces — ICardMoveAware (single OnCardMoved method) and ILocationChangeAware (single OnLocationReplaced method) — and auto-discovery caches built at match initialization.

AbilityManager builds a _cardMoveAwareEffects list once at Initialize(), refreshed after each card reveal. OnCardMovedFromLane() iterates the list — no effect is hard-wired. LocationManager builds a _locationChangeAwareEffects list after locations load. ClearLocationRestrictions() iterates it — no restriction MonoBehaviour is hard-wired. LocationAbilityStateCleaner.ResetAllLocationAbilityStates() now scans all loaded LocationAbility and CardEffect ScriptableObjects for ICleanableEffect and calls CleanupState() on each, replacing nine explicit per-class static calls and three explicit ResetForNewMatch() calls in GameManager.

LucanMerricEffect, VarisEffect, and DaishoReiEffect lost their dead GameEvents.OnMatchStarted subscriptions and gained ICleanableEffect.CleanupState() so their reset is guaranteed. TheFurnaceAbility and ClocktowerAbility lost their GameEvents.OnCardMoved and LocationManager.OnLocationReplaced subscriptions and now receive those calls through the manager caches.

No game behavior changed. New cards and locations automatically participate in all three contracts by implementing the appropriate interface.

Cross-Match Ongoing Card Buff Leakage — Lucan Merric / Varis / Daisho Rei

Root cause: LucanMerricEffect, VarisEffect, and DaishoReiEffect track live match state in static dictionaries and subscribed to GameEvents.OnMatchStarted in OnEnable() to clean that state when a new match begins. GameManager.CleanupManagers() calls GameEvents.ClearAllEvents(), which sets the entire OnMatchStarted delegate to null and permanently removes these subscriptions. ScriptableObjects do not re-trigger OnEnable between matches. When the next match fires TriggerMatchStarted, none of them receive the signal, their cleanup methods never run, and the previous match’s state carries forward. Lucan’s +3 aura was confirmed applying to cards in matches where Lucan was never played.

Fix: Added a public static ResetForNewMatch() method to each of the three effects. GameManager.CleanupManagers() now calls all three directly before ClearAllEvents() fires. Additionally, NotifyCardRevealed and OnCardSummoned in LucanMerricEffect now validate that a Lucan instance ID from activeLucanInstances actually exists on the current board before applying any buff. Stale entries are pruned immediately.

Ashborn — Displaying and Charging Cost 2 Instead of 0

Root cause: Every callsite calculating Ashborn’s play cost used the wrong evaluation order: (1) check Ashborn’s condition and set cost to 0, then (2) apply CostModificationTracker on top. If any effect had registered a +2 modifier against Ashborn’s instance ID, that delta was added to 0, yielding cost 2. This existed identically in all four callsites: CardDisplay, EnergyManager, LocationCardDropZone, and OpponentAI.

Fix: Reversed evaluation order at all four callsites. CostModificationTracker and Gift reductions apply first against the base cost. Ashborn’s override runs last as an absolute final value — no subsequent modifier can increase it.

AppLoader, Build Stability & Rank Display

AppLoader Bar — Stuck at 100%, Scene Never Transitioning

Root cause: AppBootLoader uses fastBootMode = true, which calls FastBootSequence() directly and returns early — skipping InitializeLoaderBar() and every UpdateProgress() call. The LoaderBar Image was type Simple with no fill concept, so it appeared permanently full.

Fix: A new LoaderBarAnimator component was attached to the LoaderBar GameObject. It forces Image.Type to Filled / Horizontal / Left on Start() and animates fillAmount from 0→1 over 3 seconds. FastBootSequence was updated to drive the same Image with real progress — 0→0.9 while waiting for UnityServicesInitializer, then 1.0 before loading the next scene.

Build Jitter — Input System Quit/Restart Re-enabled by AppLoader Changes

Root cause: Two canvas layout rebuilds were forced during the Awake phase — one from LoaderBarAnimator.Awake() changing Image.Type, and one from AppBootLoader.FastBootSequence() calling InitializeLoaderBar() before its first yield. In Unity 6, forcing a canvas rebuild during Awake causes InputSystemUIInputModule to process events prematurely, triggering the spurious Application.wantsToQuit bug the project already had a fix for.

Fix: LoaderBarAnimator.Awake() now only grabs the Image reference and zeros fillAmount — no type change during Awake. The type is set in Start(). FastBootSequence was reordered to yield return null first so InitializeLoaderBar() runs in Frame 1 after all Start() methods complete.

Build Window Resize Jitter

Root cause: Window resizing on Windows triggers a brief OS focus loss event, which Unity 6 can interpret as a spurious Application.wantsToQuit signal. PreventPrematureQuit only covered quit events during scene transitions — focus changes from resizing fell outside its safe window.

Fix: PreventPrematureQuit now hooks Application.focusChanged. Every focus-lost/regained cycle resets the safe window for 1 second, blocking spurious quits during or immediately after a resize. BuildJitterFix was also updated to call Screen.fullScreenMode = FullScreenMode.MaximizedWindow at runtime.

Profile Rank Display — Showing Rank 3 While Trophaeum Shows Rank 4

Root cause: RankLeaderboardManager.UpdateRuntimeInfo() was called first in UpdateLeaderboard, setting localPlayerRank to the new value. The rank-change check below it then compared the new rank against itself — always equal — so OnLocalPlayerRankChanged never fired. ProfileStatsDisplay subscribed to that event and never received a signal after startup.

Fix: Rank change detection and OnLocalPlayerRankChanged dispatch were moved into UpdateRuntimeInfo() itself. Any update to any entry that changes the local player’s sorted position now fires the event, whether triggered by the local player’s own data, another player’s score update, or incoming cloud data.

Rivals — Winner Not Added to Loser’s Rivals After Real Player Matches

Root cause: Both paths in EndGamePanelManager that add rivals only handled cloud bot opponents or shadow deck challenges. For all real player matches — normal matchmaking, direct challenge, bounty challenge — both methods silently returned early. The opponent’s full profile (name, rank points, cosmetics, PLR_ guid) was sitting in _currentOpponentBot the whole time.

Fix: Both methods were given an else if (CurrentOpponentIsRealPlayerSnapshot) branch. When the local player loses, AddCurrentOpponentAsRival builds rival data from _currentOpponentBot and calls RivalManager.AddRival directly. When the local player wins, AddPlayerAsRivalToBot calls the new RivalManager.AddRivalToRealPlayer, which invokes the new addRivalToRealPlayer cloud endpoint. That endpoint resolves the loser’s Unity auth ID via player_guid_to_auth, reads their rivals, deduplicates, enforces the max-3 cap, and writes back.

Emote System

EmotePublicDisplayContainer Inactive — Coroutine Could Not Start

Root cause: EmoteDisplayController.Awake() called gameObject.SetActive(false) to hide the container on startup. Unity does not allow StartCoroutine on an inactive GameObject, so the first time a player selected an emote the coroutine driving the display and bobble animation threw an error and silently failed.

Fix: Removed all SetActive calls from EmoteDisplayController. The GameObject stays active at all times. Visibility is driven exclusively by CanvasGroup.alpha — 0 when hidden, 1 when showing. ClearQueue and ProcessQueue updated to match.

Opponent Emote Triggering on Every Event With Only One Emote in the Library

Root cause: SelectBotEmote had a fallback that returned a random emote from the full owned list when no owned emote matched the requested context. With only one emote in the library (Laughing, tagged OnTaunt), every context miss fell through to that emote, making it fire for greetings, card plays, round ends, and everything else. OnCardPlayed probability weights were also 50–60%, and that event fires on every ability resolution during reveal, compounding the frequency.

Fix: Removed the fallback in SelectBotEmote. A context miss now returns null and the display is skipped. Opponents will only emote when they own something contextually appropriate — the system becomes more expressive naturally as more emotes with varied contexts are added. OnCardPlayed probability weights pulled back to 15–25% across all personalities.

Emote System — Cloud Code Scope Clarification

Cloud code is only required when currency changes hands, because currency deduction must be validated server-side. The purchaseEmote endpoint handles rank point and scrip purchases — it validates balance, deducts currency, and writes the entitlement to Cloud Save. All other grant paths (milestone rewards and default unlocked emotes) use the existing local EntitlementSystem.GrantEntitlement() call, identical to how card backs and profile frames already work.

Bounty Match

Hunter Wins — Scrip Not Appearing in ScripBox

Root cause: Two bugs compounding. First, BountyManager.cs serialized didWin as the string “true” or “false”. In JavaScript, any non-empty string is truthy — including “false” — making the Cloud Code win/loss branch unreliable. Second, the ClaimBounty endpoint used recipientId (the custom PLR_ GUID) as the Cloud Save storage key. Every other scrip endpoint uses context.playerId (the Unity Authentication player ID), which is the actual key. ClaimBounty was reading and writing to a phantom record that never intersected with the player’s real account.

Fix: BountyManager.cs now sends didWin as a native boolean. In ClaimBounty, the hunter win case uses context.playerId directly. For the loss case where a real player defends successfully, the endpoint resolves the defender’s Unity Auth ID from player_guid_to_auth before crediting their account. The same GUID-to-Auth-ID resolution was applied to the RP deduction section, which had the identical phantom-record bug.

Bounty Matches Had No Competitive Stakes

Context: The bounty match mode was a pure currency exchange with no rank point consequences. Hunters risked nothing on a loss. Targets gained nothing from a win beyond stopping the scrip transfer.

Redesign: Bounty matches now carry real rank point consequences for both players while keeping scrip as the bounty-specific reward currency. Hunter wins: hunter receives 100% of the bounty in scrip, target loses rank points as in a standard match, hunter gains no rank points. Hunter loses: hunter loses rank points as in a standard loss, target receives 50% of the bounty scrip as a defense bonus, target gains no rank points. Neither player can gain rank points from a bounty match. Both players now have real stakes regardless of which side of the bounty they are on.

Bot Retreat System & Delayed Wager Mechanic

Wager Increases Taking Effect Immediately

Root cause: WagerSystem.PressWager() incremented a single total press counter and recalculated the current wager on the spot, giving no gap between pressing and the higher stake locking in.

Fix: Wager presses are now split into pendingPresses and appliedPresses. PressWager() only increments pending. SetCurrentRound() applies pending to applied at the start of each new round. GetCurrentWager() is based on applied presses only. GetPendingWager() calculates what the wager will become next round. The UI now shows both values — e.g. “2 → 8 RP”. Retreating in the same round the button was pressed costs only the current wager.

Opponent Had No Mechanism to Retreat Based on Board State

Root cause: The opponent had no concept of evaluating whether a match was worth continuing. Opponents would play all seven rounds regardless of how far behind they were, losing significantly more RP than necessary — especially for passive opponents protecting offline players’ rank standings.

Fix: Three new systems were added. BoardStateEvaluator calculates the power deficit across all three locations and produces a 0–1 threat score based on deficit size, location control, and turn phase. BotRetreatEvaluator applies thresholds by mode — passive opponents (protecting offline player snapshots) retreat earlier at lower deficits (15–20 power, losing 2+ locations), engaged opponents fight longer and only retreat in blowout scenarios (30–40+ power deficit, losing all three locations, turn 5 or later). RetreatSystemConfig is a ScriptableObject that exposes all thresholds for tuning. The retreat check runs at the start of every opponent turn. When triggered, it calls EndGamePanelManager directly with a player victory using the current wager.

Opponent Would Press Wager While Already Near Retreat Threshold

Root cause: WagerManager.AIDecideWager() had no awareness of whether the opponent was already in a losing position that might trigger a retreat. An opponent could press wager in round 3 and then retreat in the same round, unnecessarily escalating the penalty.

Fix: AIDecideWager() now calls ShouldConsiderRetreating() before pressing wager. If the retreat evaluator determines the opponent is near the retreat threshold, the wager press is skipped entirely.

Leaderboard & Faction Display

Leaderboard Entry Showing Local Player’s Rank Points for Every Row

Root cause: The nested Rankbox inside each LeaderboardEntry prefab had a RankPointsDisplay component that auto-subscribed to RankPointService.Instance.Balance — the local player’s balance — so every row displayed your own rank points.

Fix: Removed RankPointsDisplay from the nested TMP. Added a playerRankPointsText serialized field to LeaderboardEntryDisplay and set it explicitly from currentEntry.rankPoints during display updates.

BountyClaim Button on LeaderboardEntry Was Redundant

Root cause: The LeaderboardEntry had a BountyClaim button that always claimed the first bounty on a player. The PlayerDetailPopupPanel already has three individual claim buttons each wired to a specific bounty by index.

Fix: Removed the BountyClaim button from the LeaderboardEntry prefab and stripped all related code from LeaderboardEntryDisplay. Bounty claiming now happens exclusively through the player profile popup.

FactionMemberEntry Replaced by LeaderboardEntry

Root cause: Faction scenes used a separate FactionMemberEntry prefab that duplicated much of what LeaderboardEntry already does — profile display, rank lookup, challenge button — with less functionality and its own maintenance burden.

Fix: Added a Setup(playerId, factionId, isLeader) method to LeaderboardEntryDisplay for faction contexts. A isFactionMode flag controls a visibility pass that hides elements irrelevant to faction lists — RankText, DeckChallenge, CurrentBounties, LBEntryFrame, and Rankbox. All faction scenes now instantiate the LeaderboardEntry prefab. FactionMemberEntry is no longer referenced.

Rank Points Not Loading in Faction Scenes Without Visiting Leaderboard First

Root cause: The faction-mode Setup() call created a minimal LeaderboardEntry with only the playerId, leaving rankPoints at 0. Even after fixing that, the leaderboard manager had no cached data if the player navigated directly from the main menu to a faction scene.

Fix: PlayerDetailPopupController now captures rank points at the point of profile loading regardless of how data is retrieved — from the leaderboardScore in the getPlayerProfiles Cloud Code response, from cached leaderboard metadata, or from CloudBotManager for opponents. LoadPlayerRankPoints() reads from this stored value rather than querying RankLeaderboardManager.

PlayerDetailPopupPanel Rankbox Showing Local Player’s Rank Points

Root cause: The Rankbox in PlayerDetailPopupPanel had its PlayerRankPoints TMP driven by a RankPointsDisplay component, which always reads the local player’s balance regardless of whose profile was open.

Fix: Removed the RankPointsDisplay component from this prefab instance. Added a playerRankPointsText reference to PlayerDetailPopupController and populate it from the viewed player’s profile data after loading.

Cosmetic Persistence & Session Management

Player Cosmetics Not Sticking After Login

Root cause: UpdateMetadataAsync() had a guard that skipped the update if lastSubmittedScore < 0. Since lastSubmittedScore is only set after a match is played, any player who changed cosmetics without playing a match would never push metadata to the leaderboard.

Fix: Replaced lastSubmittedScore with RankPointService.Instance.Balance so metadata can always be submitted regardless of whether a match was played.

Cosmetic Changes Not Triggering Leaderboard Metadata Update

Root cause: OnCosmeticEquipped and OnNameplateColorChanged in CloudProfileService only called MarkForSave(), persisting to Cloud Save but never updating leaderboard metadata.

Fix: Both handlers now also call UnityLeaderboardService.Instance.UpdateMetadataAsync() so cosmetic changes are reflected immediately on the leaderboard.

Passive Match Win Erasing Opponent’s Leaderboard Cosmetics

Root cause: The recordPassiveMatchParticipation endpoint submitted only { playerGuid } as leaderboard metadata when updating the passive player’s score, overwriting all existing metadata.

Fix: The endpoint now fetches the passive player’s full player_profile from Cloud Save before submitting, preserving their name, profile image, frame, and nameplate color.

Challenge Button Visibility Logic

Root cause: The challenge button was visible for all players regardless of active or passive status.

Fix: For real players (PLR_ prefix), the button now calls PlayerSessionStatusService.IsPlayerActive() asynchronously. Active players hide the button. Passive players show it. Opponents that are offline are always challengeable. On error, the button defaults to hidden.

Session Cache Returning Stale Passive Status

Root cause: Session status was cached for 30 seconds. A player could log in, become active, and still appear passive for up to 30 seconds.

Fix: Reduced cache lifetime to 5 seconds. Added OnCacheCleared event to PlayerSessionStatusService. LeaderboardDisplayManager subscribes and re-triggers all entry button state checks when the cache clears.

Cloud Code Cooldown Blocking Session Status Updates on Login

Root cause: The updatePlayerSessionStatus Cloud Code had a 5-second cooldown. Multiple systems triggering status updates on login could cause the ACTIVE update to be rejected, leaving the player marked passive.

Fix: Reduced the Cloud Code cooldown from 5000ms to 2000ms.

Challenge Button Logic Inverted

Root cause: IsPlayerActive() in PlayerSessionStatusService was returning a value that did not match actual session state — PASSIVE returned true, ACTIVE returned false, causing the challenge button to show for active players and hide for passive ones.

Fix: Added explicit logging to both PlayerSessionStatusService and the Cloud Code endpoint to surface the exact value returned and whether the button will show or hide, so the inversion can be caught at the source.

Passive Matchmaking System

AppLifecycleSessionTracker Firing in Unity Editor

Root cause: OnApplicationFocus and OnApplicationPause fire unpredictably in the Unity Editor when the editor window loses focus during Play Mode, causing the tracker to immediately mark players as inactive right after login.

Fix: Added a disableInEditor flag (enabled by default) to AppLifecycleSessionTracker. The tracker is only active in standalone builds.

WhileAway Display Not Appearing After Auto-Login

Root cause: WhileAwayDisplay.FetchAndDisplayStatsAsync() was only called by LoginManager, which only runs during manual login. The auto-login path in AppBootLoader skipped it entirely.

Fix: AppBootLoader now calls MarkPlayerAsActiveAsync() and hooks into SceneManager.sceneLoaded to initialize WhileAwayDisplay when MainMenu loads after auto-login. WhileAwayDisplay also gained a Start() coroutine as a fallback.

Cloud Code Endpoint Name Mismatch (404 Error)

Root cause: The getAndClearWhileAwayStats endpoint was deployed as getAndClearWhileAwayStatus (Status vs Stats). Unity Cloud Code returns a generic 404 with no hint about the name mismatch.

Fix: Updated the client to call the correct deployed endpoint name.

Passive Match Not Recording for Direct Challenges

Root cause: BotMatchIntegration.RecordPassiveMatchIfApplicable() was calling PassiveMatchService.Instance.IsPlayerActiveAsync(), a method that does not exist on that service. The correct service is PlayerSessionStatusService. The async failure was swallowed silently.

Fix: Corrected the service call and added step-by-step debug logging throughout the flow.

PassiveMatchResponse Deserialization Failure

Root cause: The recordPassiveMatchParticipation endpoint returns an updatedStats object in its response, but the C# PassiveMatchResponse class did not declare it. Unity’s JSON deserializer throws on undeclared fields.

Fix: Added the updatedStats field and its nested UpdatedStats class to PassiveMatchResponse to match the full Cloud Code response shape.

PlayerGuidService Returning Stale GUID After Account Switch

Root cause: PlayerGuidService is a DontDestroyOnLoad singleton that caches the player’s GUID in memory and never cleared it on sign-out. When a different account signed in, all systems operated on the previous player’s GUID.

Fix: Added a ClearCache() method to PlayerGuidService and called it in SignOutButton before authentication sign-out.

Leaderboard Showing Stale Data After Account Switch

Root cause: RankLeaderboardManager holds an in-memory entry list that was never cleared between sessions. After switching accounts the leaderboard reflected the previous account’s data.

Fix: SessionManager.ClearAllSessionData() now also calls RankLeaderboardManager.Instance.ClearLeaderboard() so the leaderboard rebuilds fresh from Cloud Save on every login. Note: deleting the persistent app data folder is required once to clear any data corrupted before this fix.

FirstFrameTracker Cluttering Console Logs

Root cause: SceneLoadTimer spawned a FirstFrameTracker GameObject on every scene load, logging 10 frame-time entries per scene and burying useful diagnostic output.

Fix: Disabled FirstFrameTracker creation. Scene load time is still logged once per scene.