dotnet/orleans · error · ApplicationException

Can't join game once its over

Error message

Can't join game once its over

What it means

Thrown by TicTacToe GameGrain.AddPlayerToGame when the game's state is already Finished — you cannot add a player to a completed game. The grain is a state machine (AwaitingPlayers -> InPlay -> Finished) and this guards an illegal transition.

Source

Thrown at samples/TicTacToe/Grains/GameGrain.cs:50

    {
        // make sure newly formed game is in correct state
        _playerIds = new List<Guid>();
        _moves = new List<GameMove>();
        _indexNextPlayerToMove = -1;  // safety default - is set when game begins to 0 or 1
        _board = new int[3, 3] { { -1, -1, -1 }, { -1, -1, -1 }, { -1, -1, -1 } };  // -1 is empty

        _gameState = GameState.AwaitingPlayers;
        _winnerId = Guid.Empty;
        _loserId = Guid.Empty;

        return base.OnActivateAsync(token);
    }

    // add a player into a game
    public Task<GameState> AddPlayerToGame(Guid player)
    {
        // check if its ok to join this game
        if (_gameState is GameState.Finished) throw new ApplicationException("Can't join game once its over");
        if (_gameState is GameState.InPlay) throw new ApplicationException("Can't join game once its in play");

        // add player
        _playerIds.Add(player);

        // check if the game is ready to play
        if (_gameState is GameState.AwaitingPlayers && _playerIds.Count is 2)
        {
            // a new game is starting
            _gameState = GameState.InPlay;
            _indexNextPlayerToMove = Random.Shared.Next(0, 1);  // random as to who has the first move
        }

        // let user know if game is ready or not
        return Task.FromResult(_gameState);
    }

    // make a move during the game

View on GitHub (pinned to fca799fa70)

Solutions

  1. Create or join a fresh game (new grain key) instead of reusing a Finished one.
  2. Reset the grain (the OnActivateAsync path sets AwaitingPlayers) — but only a new activation/deactivation cycle does that, so prefer a new game id.
  3. Have the client check the returned GameState before attempting to join.

Example fix

// before
var state = await game.AddPlayerToGame(playerId); // throws if Finished

// after: verify state, or start a new game
var state = await game.GetState();
if (state == GameState.Finished) game = GrainFactory.GetGrain<IGameGrain>(Guid.NewGuid());
await game.AddPlayerToGame(playerId);
Defensive patterns

Strategy: validation

Validate before calling

// Check the game state before joining
var state = await game.GetState();
if (state == GameState.Finished || state == GameState.InPlay)
    return; // do not call AddPlayerToGame

Type guard

null

Try / catch

try { await game.AddPlayerToGame(playerId); }
catch (ApplicationException ex) when (ex.Message.Contains("over"))
{ /* start a new game instead */ }

Prevention

When it happens

Trigger: A client calls AddPlayerToGame(playerId) on a game grain whose _gameState is GameState.Finished (a winner has been decided). Also thrown implicitly via the sibling check for InPlay with a different message.

Common situations: Joining a game id that already completed; a stale UI showing an old game as joinable; reusing a finished game grain id instead of creating a new one.

Related errors


AI-assisted analysis of dotnet/orleans@fca799fa70 (2026-08-13). Data as JSON: /api/errors/a9f34705e6f1840f. Report an issue: GitHub.