Base CardGame component for a networked card game. Manages synced metadata, seats, lifecycle (start/round/clear), RPCs for player interactions, turn timer, hints/announcements, sound playback, spawning cards, and package mounting for cloud-pulled games. Designed to be subclassed by seated or board game archetypes.
using System.Threading.Tasks;
namespace CardGames;
public enum GameState
{
Waiting, // lobby, waiting for the host to start
Betting, // players placing wagers (games without betting never enter this)
Dealing, // cards going out
PlayerTurn, // a seated player is acting
Resolving, // dealer/house plays, scoring
GameOver // round finished, results shown
}
/// <summary>
/// The shared spine for every card game. Lives on a "gamemode" GameObject and is host-authoritative:
/// the host owns the shoe and all rules; clients only send actions and render synced state.
///
/// Don't subclass this directly - pick an archetype: <see cref="SeatedGame"/> for turn-based seated
/// games (Blackjack, Poker) or <see cref="BoardGame"/> for pile/drag games (Solitaire). A game fills in
/// <see cref="OnRoundStart"/> + <see cref="OnPlayerAction"/> plus the hooks its archetype exposes.
/// See code/Framework/README.md for the "add a game" guide.
/// </summary>
public abstract class CardGame : Component
{
/// <summary>
/// Synced path to this game's <see cref="GameDefinition"/> (.cggame asset). Set by the director when
/// the game spawns, so every client resolves the same metadata, deck and presentation settings.
/// </summary>
[Sync] public string DefinitionPath { get; set; }
/// <summary>
/// For a game pulled from the cloud: the package ident it came from (e.g. "org.mygame").
/// Synced so remote clients can mount the same package and resolve its assets. Empty for built-in games.
/// </summary>
[Sync] public string PackageIdent { get; set; }
private GameDefinition _definition;
/// <summary>
/// This game's metadata/presentation. Normally resolved from <see cref="DefinitionPath"/> (the .cggame
/// asset); assignable so a test or tool can inject one in code without mounting an asset.
/// </summary>
public GameDefinition Definition
{
get => _definition ??= string.IsNullOrEmpty( DefinitionPath ) ? null : ResourceLibrary.Get<GameDefinition>( DefinitionPath );
set => _definition = value;
}
/// <summary>
/// Is this game's definition resolvable yet? Always true for built-in games; briefly false on a remote
/// client while a game's cloud package is still downloading. The HUD shows a loading note until then.
/// </summary>
public bool DefinitionReady => Definition is not null;
/// <summary>
/// Player-facing name of this game. A game pulled from the cloud takes its title from the package it
/// came from; a built-in game (no package) falls back to a readable name derived from its asset path.
/// </summary>
public string Title
{
get
{
if ( !string.IsNullOrEmpty( PackageIdent )
&& Package.TryGetCached( PackageIdent, out var pkg )
&& !string.IsNullOrEmpty( pkg?.Title ) )
return pkg.Title;
return GameDefinition.NiceName( DefinitionPath );
}
}
// A client viewing a game pulled from the cloud needs its package mounted before its definition, deck
// and prefab assets resolve locally. The host already mounted it when starting; everyone else does it once.
private bool _mountStarted;
// Client-local: how long this client has had this game's table open, for GameStats.RecordPlaytime when
// it closes (OnDestroy) - not synced, since it's this player's own time spent, not a shared game fact.
private RealTimeSince _sessionStarted;
protected override void OnStart()
{
_sessionStarted = 0;
if ( !string.IsNullOrEmpty( PackageIdent ) )
_ = EnsurePackageMounted();
}
// Every client's copy of this game object is destroyed when the table closes (the host quitting to the
// menu, or leaving the round some other way) - record the time this client spent at it either way.
protected override void OnDestroy() => GameStats.RecordPlaytime( this, _sessionStarted );
// Download + mount the package this game came from, so its assets resolve on this client.
private async Task EnsurePackageMounted()
{
if ( _mountStarted ) return;
_mountStarted = true;
// Already available (the host, or anyone who mounted it for an earlier game)? Nothing to do.
if ( Definition is not null ) return;
var pkg = await Package.FetchAsync( PackageIdent, false );
if ( pkg is null ) return;
await pkg.MountAsync( withCode: true );
_definition = null; // force Definition to re-resolve now assets exist
CardMesh.SetSize( CardMesh.DefaultWidth * CardScale ); // apply this game's card scale, now that we know it
}
public DeckDefinition Deck => Definition?.Deck;
public int MinPlayers => Definition?.MinPlayers ?? 1;
public int MaxPlayers => Definition?.MaxPlayers ?? 4;
public bool SupportsMultiplayer => Definition?.Multiplayer ?? true;
public float CameraHeight => Definition?.CameraHeight ?? 0f;
public float CardScale => Definition?.CardScale ?? 1f;
/// <summary>
/// How many full decks make up the shoe. A gameplay tunable on the rules prefab.
/// </summary>
protected virtual int ShoeCount => 1;
/// <summary>
/// Where a seat's hand is laid out at, exposed so UI (SeatTag) can pin a player's tag over the same spot their cards are
/// </summary>
public virtual Transform? SeatHandSpot( Seat seat ) => null;
/// <summary>
/// Screen-space vertical offset (in scaled pixels, positive = below the hand) for a seat's name tag
/// </summary>
public virtual float SeatTagDrop( Seat seat, float defaultDrop ) => defaultDrop;
/// <summary>Local predicate: can the local player click/drag this loose hand card right now?</summary>
public virtual bool CanInteractCard( CardObject card ) => false;
/// <summary>
/// The text for this seat's on-table hand-value decal, as the local viewer should see it (null/empty =
/// none). Default returns the synced <see cref="Seat.HandLabel"/>; a game overrides this to compute a
/// private, local-only value (e.g. a Poker player's own hand before showdown).
/// </summary>
public virtual string HandValueText( Seat seat ) => seat?.HandLabel;
/// <summary>
/// Every hand-value decal this game wants drawn right now (a stable key, a world spot, and the text).
/// Read on every client by <c>HandValueDecalRenderer</c>; default none. Seated games yield one per seat.
/// </summary>
public virtual IEnumerable<HandValueDecal> HandValueDecals() => Array.Empty<HandValueDecal>();
/// <summary>
/// Does this game's design permit flinging cards at all (a purely cosmetic toss that tumbles, then lerps
/// home)? Default yes - override to <c>false</c> to opt a game out, which also hides the per-table
/// "Card throwing" toggle below.
/// </summary>
protected virtual bool SupportsThrowing => true;
/// <summary>
/// Per-table toggle for the cosmetic card toss. Shown in the lobby for every game that
/// <see cref="SupportsThrowing"/> (hidden otherwise, via <see cref="ShowSetting"/>).
/// </summary>
[Property, Sync, Setting]
[Title( "Card throwing" ), Description( "Let players fling their own cards across the table." ), Group( "Table" )]
public bool CardThrowing { get; set; } = true;
/// <summary>
/// Effective gate read by the drag UI and the throw RPCs: throwing happens only when the game supports
/// it and the table has it switched on.
/// </summary>
public bool AllowThrowing => SupportsThrowing && CardThrowing;
/// <summary>Local predicate: is this loose card one the local player owns (and so may throw)? Default no.</summary>
public virtual bool OwnsCard( CardObject card ) => false;
/// <summary>
/// Client to host: fling a loose card the caller owns (a cosmetic toss). The host validates ownership
/// (the card is in the caller's seat) and the opt-out, then turns it loose as physics.
/// </summary>
[Rpc.Host]
public void RequestThrowCard( CardObject card, Vector3 velocity, Vector3 angularVelocity )
{
if ( !Networking.IsHost || !AllowThrowing || card is null ) return;
if ( SeatOf( Rpc.Caller ) is not { } seat || !seat.Cards.Contains( card ) ) return; // only your own
card.Throw( velocity, angularVelocity );
}
/// <summary>Local predicate: can the local player click this pile right now (e.g. the draw pile)?</summary>
public virtual bool CanInteractPile( Pile pile ) => false;
/// <summary>The pile a dragged hand card is dropped onto to play it (e.g. the discard). Null = none.</summary>
public virtual Pile InteractDropPile => null;
/// <summary>Client to host: the local player clicked or dragged this hand card to play it.</summary>
[Rpc.Host]
public void InteractCard( CardObject card )
{
if ( !Networking.IsHost || card is null ) return;
OnInteractCard( SeatOf( Rpc.Caller ), card );
}
/// <summary>Client to host: the local player clicked this pile (e.g. the draw pile to draw).</summary>
[Rpc.Host]
public void InteractPile( Pile pile )
{
if ( !Networking.IsHost || pile is null ) return;
OnInteractPile( SeatOf( Rpc.Caller ), pile );
}
/// <summary>Host: handle a direct card interaction for the caller's seat. Default none.</summary>
protected virtual void OnInteractCard( Seat seat, CardObject card ) { }
/// <summary>Host: handle a direct pile interaction for the caller's seat. Default none.</summary>
protected virtual void OnInteractPile( Seat seat, Pile pile ) { }
/// <summary>
/// Does this game use chips/betting? Drives whether the HUD shows the wallet. Default no.
/// </summary>
public virtual bool UsesBetting => false;
[Sync] public GameState State { get; set; } = GameState.Waiting;
[Sync] public int CurrentSeat { get; set; } = -1;
[Sync] public string StatusText { get; set; } = "";
/// <summary>Secondary status under the status bar - a small label (<see cref="Subtitle"/>) and a big value
/// (<see cref="SubtitleValue"/>), e.g. "POT" / "75". Both empty = hidden.</summary>
[Sync] public string Subtitle { get; set; } = "";
[Sync] public string SubtitleValue { get; set; } = "";
// - Turn timer (generic). A game with a positive TurnTimeLimit gets a per-turn clock for human players;
// when it runs out the host acts for them via OnTurnTimeout. The HUD shows TurnFraction as a bar. -
/// <summary>Seconds a human has to act before the game acts for them (<see cref="OnTurnTimeout"/>). 0 = off.</summary>
protected virtual float TurnTimeLimit => 0f;
/// <summary>When the current turn's clock runs out. Synced (the sim clock is shared), so the bar animates
/// on every client off the same value.</summary>
[Sync] public TimeUntil TurnExpires { get; set; }
private int _timerSeat = -1; // host: the seat the running clock belongs to
// A clock is showing when it's a human's turn in a game that uses one (all from synced state).
private bool TurnClockActive
{
get
{
if ( TurnTimeLimit <= 0f || State != GameState.PlayerTurn ) return false;
return Seats.FirstOrDefault( s => s.Index == CurrentSeat ) is { Occupied: true, IsBot: false };
}
}
/// <summary>Seconds left on the current turn's clock, or 0 when none is running.</summary>
public float TurnSecondsLeft => TurnClockActive ? Math.Max( 0f, (float)TurnExpires ) : 0f;
/// <summary>The turn clock as a 0..1 fraction, for a HUD progress bar. 0 = no clock running.</summary>
public float TurnFraction => TurnClockActive ? Math.Clamp( (float)TurnExpires / TurnTimeLimit, 0f, 1f ) : 0f;
/// <summary>Host: the acting player ran out of time. Default does nothing; games act for them (fold/check).</summary>
protected virtual void OnTurnTimeout( Seat seat ) { }
/// <summary>
/// Host: set the game phase, and optionally the status line, in one call - so the two never drift
/// apart and you can't forget to update one. Pass <paramref name="status"/> null to leave it unchanged.
/// </summary>
protected void SetPhase( GameState state, string status = null )
{
State = state;
if ( status is not null )
StatusText = status;
}
protected Shoe Shoe { get; set; }
/// <summary>
/// Debug/test hook: the exact face order (top first) the next round's shoe should deal, instead of a
/// shuffle. Consumed by <see cref="StartRound"/> and cleared. Null = deal a normal shuffled shoe.
/// </summary>
public IReadOnlyList<int> RiggedDeal { get; set; }
public IEnumerable<Seat> Seats => Scene.GetAllComponents<Seat>().OrderBy( s => s.Index );
public Seat LocalSeat => Seats.FirstOrDefault( s => s.IsLocal );
public bool IsMyTurn => State == GameState.PlayerTurn && LocalSeat is { } s && s.Index == CurrentSeat;
protected TableAnchor Table => _table ??= Scene.Get<TableAnchor>();
private TableAnchor _table;
/// <summary>The scene's sound catalog (one instance, on the director). Null if none is placed.</summary>
protected GameSounds Sounds => Scene.Get<GameSounds>();
/// <summary>The director that spawned this game (for round/menu control from game code).</summary>
protected GameDirector Director => Scene.Get<GameDirector>();
protected override void OnEnabled()
{
CardMesh.SetSize( CardMesh.DefaultWidth * CardScale );
}
/// <summary>
/// Host: sit down everyone already connected.
/// </summary>
public void HostSetup()
{
if ( !Networking.IsHost ) return;
foreach ( var c in Connection.All )
SeatPlayer( c );
OnHostSetup();
StatusText = "Waiting for players - host can start when ready.";
}
/// <summary>
/// Hook: the host has seated the connected players. A game can fill the rest of the table here (e.g.
/// poker seats AI bots) so it has enough players to start before the first deal.
/// </summary>
protected virtual void OnHostSetup() { }
/// <summary>
/// Host: give a connection a seat (one seat per player). Safe to call repeatedly.
/// </summary>
public bool SeatPlayer( Connection connection )
{
if ( !Networking.IsHost ) return false;
if ( Seats.Any( s => s.Player == connection.Id ) ) return true; // already seated
if ( Seats.Count() >= MaxPlayers ) return false; // table full
var go = new GameObject( true, "Seat" );
var seat = go.Components.Create<Seat>();
go.NetworkSpawn();
seat.Index = Seats.Any() ? Seats.Max( s => s.Index ) + 1 : 0; // stable join-order id
seat.Player = connection.Id;
seat.Currency = Definition?.StartingStack ?? 0; // seed the chip bankroll
OnSeatsChanged();
return true;
}
/// <summary>
/// Host: remove a player's seat (and their cards) when they leave.
/// </summary>
public void RemovePlayer( Connection connection )
{
if ( !Networking.IsHost ) return;
var seat = Seats.FirstOrDefault( s => s.Player == connection.Id );
if ( seat is null ) return;
foreach ( var card in seat.Cards )
card.GameObject.Destroy();
seat.GameObject.Destroy();
OnSeatsChanged();
}
/// <summary>
/// Hook: the set of seats changed (someone joined or left). Seated games relay their hands; games
/// that don't keep seat hands (board games) ignore it.
/// </summary>
protected virtual void OnSeatsChanged() { }
protected Seat SeatOf( Connection connection ) => Seats.FirstOrDefault( s => s.Player == connection.Id );
/// <summary>
/// Hook: should the lobby show the <c>[Setting]</c> named <paramref name="name"/> for this game right now?
/// Default yes; the base hides the "Card throwing" toggle when the game doesn't <see cref="SupportsThrowing"/>.
/// Override and call <c>base</c> to hide additional settings that don't currently apply.
/// </summary>
public virtual bool ShowSetting( string name )
=> name != nameof( CardThrowing ) || (SupportsThrowing && SupportsMultiplayer);
/// <summary>
/// Host UI → game: a <c>[Setting]</c> was just changed in the Waiting lobby. Only the host edits the
/// (authoritative) instance directly; the new value replicates to clients via <c>[Sync]</c>. Forwards to
/// <see cref="OnSettingsChanged"/> so a game can react live (e.g. re-seat bots when the opponent count changes).
/// </summary>
public void NotifySettingsChanged()
{
if ( Networking.IsHost )
OnSettingsChanged();
}
/// <summary>
/// Hook: a player-facing setting changed in the lobby (host only). Default does nothing. Don't read the
/// value here - read your <c>[Setting]</c> properties directly; this just tells you they may have moved.
/// </summary>
protected virtual void OnSettingsChanged() { }
/// <summary>
/// Host: deal a fresh round.
/// </summary>
public void StartRound()
{
if ( !Networking.IsHost ) return;
if ( Seats.Count( s => s.Occupied ) < MinPlayers ) return;
CardMesh.SetSize( CardMesh.DefaultWidth * CardScale ); // before layout/cards so spacing matches
ClearTable();
Shoe = RiggedDeal is null ? new Shoe( Deck, ShoeCount ) : new Shoe( RiggedDeal );
RiggedDeal = null; // a rig is one round only
State = GameState.Dealing;
OnRoundStart();
}
/// <summary>
/// Host: clear the table from outside (e.g. the director quitting the game).
/// </summary>
public void ClearTablePublic()
{
if ( Networking.IsHost )
{
ClearTable();
}
}
/// <summary>
/// Host: clear all cards and piles from the table. Archetypes extend this (e.g. to drop history).
/// </summary>
protected virtual void ClearTable()
{
foreach ( var card in Scene.GetAll<CardObject>().ToList() )
card.GameObject.Destroy();
foreach ( var pile in Scene.GetAll<Pile>().ToList() )
pile.GameObject.Destroy();
foreach ( var seat in Seats )
seat.Cards.Clear();
}
/// <summary>
/// Host: deal the opening cards and set the first turn.
/// </summary>
protected abstract void OnRoundStart();
/// <summary>
/// Host: everyone has acted - play out the house and score the round. Optional (Solitaire skips it).
/// </summary>
protected virtual void OnRoundEnd() { }
/// <summary>
/// The buttons the given seat may press right now (derived from synced state, so the HUD can call
/// it on any client). Default: none. Subclasses return e.g. Hit/Stand or Draw.
/// </summary>
public virtual IReadOnlyList<GameAction> ActionsFor( Seat seat ) => Array.Empty<GameAction>();
/// <summary>
/// Per-seat tags for the generic players panel (dealer button, all-in, ready, a hand total, …). Read on
/// every client, so derive them from synced state only. Default: none.
/// </summary>
public virtual IReadOnlyList<SeatBadge> SeatBadges( Seat seat ) => Array.Empty<SeatBadge>();
/// <summary>
/// Client to host. Resolves the caller's seat, then applies the action host-side. <paramref name="amount"/>
/// carries a button's <see cref="GameAction.Amount"/> (e.g. a chip value); 0 for plain actions.
/// </summary>
[Rpc.Host]
public void SendAction( string id, long amount = 0 )
{
var seat = SeatOf( Rpc.Caller );
if ( seat is not null )
ApplyAction( seat, id, amount );
}
/// <summary>
/// Host: validate an action is currently allowed for <paramref name="seat"/>, then apply it. The RPC
/// entry point resolves the seat from the network caller; host-side callers can apply directly. The
/// matched <see cref="GameAction"/> is handed to the game, so it never parses the id. A
/// <see cref="GameAction.Variable"/> action matches by id alone and receives the caller's amount.
/// </summary>
public void ApplyAction( Seat seat, string id, long amount = 0 )
{
if ( !Networking.IsHost || seat is null ) return;
foreach ( var action in ActionsFor( seat ) )
{
if ( action.Id != id || action.Disabled ) continue;
if ( !action.Variable && action.Amount != amount ) continue;
// A variable action carries the caller's amount (the listed Amount is just its minimum/default).
OnPlayerAction( seat, action.Variable ? action with { Amount = amount } : action );
return;
}
}
/// <summary>
/// Host: apply a validated action (Hit, Stand, a chip bet, ...) for the given seat. Switch on
/// <see cref="GameAction.Id"/> and read <see cref="GameAction.Amount"/> for parameterised actions.
/// </summary>
protected abstract void OnPlayerAction( Seat seat, GameAction action );
// - End-of-game overlay -
/// <summary>
/// The overlay shown at <see cref="GameState.GameOver"/>. Override to set the title, tone and actions;
/// the default is a neutral "Game Over" with Play Again / Keep Playing / Main Menu. Read on every client
/// (the HUD calls it), so only touch synced state.
/// </summary>
protected virtual EndScreen GetEndScreen() => new(
Title: "Game Over",
Detail: StatusText,
Tone: EndTone.Neutral,
Actions: new[] { EndAction.PlayAgain, EndAction.KeepPlaying, EndAction.MainMenu } );
/// <summary>The current end-screen spec, for the HUD.</summary>
public EndScreen EndScreen => GetEndScreen();
/// <summary>
/// Client to host: run a <see cref="EndActionKind.Custom"/> end-screen action. Play Again / Main Menu /
/// Keep Playing are handled by the HUD directly; only custom ids reach here.
/// </summary>
[Rpc.Host]
public void SendEndAction( string id )
{
if ( !Networking.IsHost || State != GameState.GameOver ) return;
OnEndAction( id );
}
/// <summary>
/// Host: handle a game-specific end-screen action (the <see cref="EndAction.Custom"/> id). Default none.
/// </summary>
protected virtual void OnEndAction( string id ) { }
// - Sound -
/// <summary>
/// Play a table sound on every client, at <paramref name="position"/> in the world. Call from host logic
/// so a sound triggered by host-authoritative state (a card dealt, a bet placed) is heard at every seat,
/// not just on the host. No-ops where the scene has no <see cref="GameSounds"/> or the slot is unassigned.
/// </summary>
[Rpc.Broadcast]
public void PlaySound( TableSound sound, Vector3 position ) => Sounds?.Play( sound, position );
/// <summary>
/// Play a sound at <paramref name="position"/>, but only on the client of <paramref name="seat"/>'s player.
/// For personal results (win/loss/push), where a shared broadcast would play everyone's outcome on every machine.
/// </summary>
[Rpc.Broadcast]
public void PlaySoundForSeat( Seat seat, TableSound sound, Vector3 position )
{
if ( seat.IsValid() && seat.IsLocal )
Sounds?.Play( sound, position );
}
/// <summary>
/// Host: spawn a card in the world. Pass <paramref name="privateTo"/> for an owner-only hand.
/// </summary>
protected CardObject SpawnCard( Card card, Transform at, bool faceUp, Connection privateTo = null )
{
var go = new GameObject( true, "Card" );
go.WorldPosition = at.Position;
go.WorldRotation = at.Rotation;
var co = go.Components.Create<CardObject>();
co.Value = card;
go.NetworkSpawn( privateTo ?? Connection.Local );
co.FaceUp = faceUp;
co.FaceIndex = faceUp ? card.FaceIndex : -1;
// A hidden card that belongs to a specific player: tell only them what it is.
if ( !faceUp && privateTo is not null )
co.RevealToOwner( card.FaceIndex );
// Start lifted above the slot, then let the caller's layout animate it down with a flip.
co.SnapTo( new Transform( at.Position + Vector3.Up * 12f, at.Rotation ) );
return co;
}
/// <summary>
/// A "do this next" suggestion shown in the HUD pill, set on demand. Empty = no hint showing.
/// </summary>
[Sync] public string HintText { get; set; } = "";
private const float AnnounceSeconds = 2.6f;
private TimeUntil _announceExpires;
/// <summary>The current announcement banner text (empty when nothing is showing).</summary>
public string AnnounceText { get; private set; } = "";
/// <summary>Bumped on every new announcement so the HUD can restart its pop-in animation.</summary>
public int AnnounceId { get; private set; }
/// <summary>True while an announcement banner should be shown in the centre of the screen.</summary>
public bool AnnounceActive => !string.IsNullOrEmpty( AnnounceText ) && !_announceExpires;
/// <summary>
/// Host → all clients: flash a transient banner in the centre of the screen (e.g. a special-effect
/// callout). Safe to call from host game logic; in solo play the host is the only client and shows it too.
/// </summary>
[Rpc.Broadcast]
public void Announce( string text )
{
AnnounceText = text;
AnnounceId++;
_announceExpires = AnnounceSeconds;
}
/// <summary>
/// Host: work out the best next move for the player. Game-specific; null = nothing to suggest.
/// </summary>
protected virtual GameHint? ComputeHint() => null;
private const float HintSeconds = 6f; // a hint fades on its own after this long
private TimeUntil _hintExpires;
/// <summary>
/// Host: show the current best move in the HUD pill and flash its cards; clears when acted on or after a few seconds.
/// </summary>
protected void ShowHint()
{
if ( !Networking.IsHost ) return;
var hint = ComputeHint();
HintText = hint?.Text ?? "No moves available - you may be stuck.";
if ( hint?.Cards is { } cards )
foreach ( var card in cards )
card?.Pulse();
_hintExpires = HintSeconds;
}
/// <summary>
/// Host: hide any showing hint (call when the board changes - the hint is now stale).
/// </summary>
protected void ClearHint() => HintText = "";
// Client-local (not synced): every client watches its own view of State to record its own outcome via
// GameStats, the same way EndScreen/GetEndScreen is computed per-client from synced state.
private GameState _lastObservedState;
private RealTimeSince _roundStarted;
protected override void OnUpdate()
{
WatchStateForStats();
if ( !Networking.IsHost ) return;
// Fade a shown hint once its timer runs out.
if ( !string.IsNullOrEmpty( HintText ) && _hintExpires )
HintText = "";
UpdateTurnTimer();
}
// A new round started - reset the clock; a round just ended - record it (played, win/loss, duration).
private void WatchStateForStats()
{
if ( State == _lastObservedState ) return;
// The lobby's browsable "state" tag - back to Waiting between rounds means the table's open to
// join again; anything else means a hand's in progress and new joiners should stay out of it.
// GameDirector seeds this "joinable" when the game first starts; this keeps it live from there.
if ( Networking.IsHost )
Networking.SetData( "state", State == GameState.Waiting ? "joinable" : "in_progress" );
if ( _lastObservedState == GameState.Waiting && State != GameState.Waiting )
_roundStarted = 0;
if ( State == GameState.GameOver )
{
GameStats.RecordRoundPlayed( this );
if ( EndScreen.Tone != EndTone.Neutral )
GameStats.RecordResult( this, EndScreen.Tone == EndTone.Win );
GameStats.RecordDuration( this, _roundStarted );
}
_lastObservedState = State;
}
// Host: start the per-turn clock when a human's turn begins (the synced TurnExpires animates the bar on
// every client) and act for them when it runs out.
private void UpdateTurnTimer()
{
if ( TurnTimeLimit <= 0f ) return;
var seat = Seats.FirstOrDefault( s => s.Index == CurrentSeat );
bool ticking = State == GameState.PlayerTurn && seat is { Occupied: true, IsBot: false };
if ( !ticking )
{
_timerSeat = -1;
return;
}
if ( _timerSeat != seat.Index ) // a new player's turn - start the clock
{
_timerSeat = seat.Index;
TurnExpires = TurnTimeLimit;
}
else if ( TurnExpires ) // ran out of time
{
_timerSeat = -1;
OnTurnTimeout( seat );
}
}
}