Game Over

- 5 mins read

A game is over when there is no more than one player remaining alive. If one player is alive at the end of the game, they are shown a winner screen which directs them back to the main menu. Eliminated players are shown a Game Over screen on the round their character starts with 0 health. Even after the game is over, the game session process is still alive and the game session summary has a :game_status of “active”. This means the game still shows in progress on the player’s dashboard and players can rejoin the ended game. The winner can actually keep playing the game, but eliminated players will be shown the Game Over screen upon joining. The next update I’ll make is to ensure the end of the game properly stops the session process and marks the session summary record as no longer active.

Starting with a test

I add a new test case for the existing feature test scenario of a player being the last character alive. The new test is failing since the game is still in progress after the player wins.

  describe "when player has last character alive" do
    setup [:register_user, :create_game_session]

    test "should show player has won", ctx do
      ctx.session
      |> sign_in(as: ctx.user)
      |> join_game_and_wait_until_loaded(ctx.join_code, "00:00")
      |> assert_text("YOU WON!")
    end

    test "game should no longer show on user dashboard", ctx do
      ctx.session
      |> sign_in(as: ctx.user)
      |> join_game_and_wait_until_loaded(ctx.join_code, "00:00")
      |> assert_text("YOU WON!")
      |> visit("/")
      |> assert_text("Welcome User")
      |> refute_has(Query.text("Games in Progress"))
    end
  end

I don’t remember exactly where the code determines a win scenario so I work backwards from the existing test cast to see which conditions cause “YOU WON!” to be rendered. At the end of a round, the game state is checked for any eliminated players and player statuses are updated. Each user’s LiveView assigns their player state to the socket and a different template is rendered depending on the status. There is no concept for the game being in play versus having concluded so I’ll need to implement that next.

I update the Game struct definition to add a :status attribute which can be either :active or :concluded.

defmodule Minotaur.GameEngine.Game do
  # …

  embedded_schema do
    field :id, :binary_id
    field :status, Ecto.Enum, values: [:active, :concluded], default: :active
    field :round_end_time, :utc_datetime
    field :players, PlayersMap
    field :registered_actions, RegisteredActionsMap
    field :round, :integer, default: 1
    field :version, :integer, default: @current_version

    embeds_one :world, World
  end

I next update the end_round function in the Session module which handles all game state transformations. I pipe the game state to a new private function to update the game status to :concluded if the game over condition is met.

defmodule Minotaur.GameEngine.Session do
  # …

  def end_round(game, round_end_time) do
    new_round_game =
      game
      |> Map.update!(:round, &(&1 + 1))
      |> Map.put(:round_end_time, round_end_time)
      |> resolve_end_of_round_world_changes()
      |> resolve_dead_pcs()
      |> Map.put(:registered_actions, %{})
      |> check_for_winner()
      |> check_for_game_over()

    {:ok, new_round_game}
  end

  defp check_for_game_over(game) do
    if map_size(game.world.player_characters) < 2 do
      %{game | status: :concluded}
    else
      game
    end
  end
end

To make the test pass, the associated GameSessionSummary record in the database will need to update its :game_status value to something other than :active otherwise the game session will show up on the user dashboard. A simple change to the update_summary_record function in the SessionServer module is all that is needed to accomplish this.

  defp update_summary_record(game, join_code) do
    Repo.transaction(fn ->
      attrs = %{
        game_id: game.id,
        join_code: join_code,
        game_status: game.status,
        latest_round: game.round,
        game_state: game
      }

      {:ok, game_session_summary} =
        %GameSessionSummary{}
        |> GameSessionSummary.changeset(attrs)
        |> Repo.insert(
          on_conflict: {:replace_all_except, [:id, :inserted_at]},
          conflict_target: [:game_id]
        )

      insert_player_summary_records(game, game_session_summary)
    end)
  end

The test is now passing, but I’m seeing some inconsistent results when I run the full test suite.

Race conditions

There seems to be some issues with the browser assertions not running before the game session timer expires to start the next round especially when running on my laptop. I am simply hardcoding the next round to start with a timestamp 1 second in the future which is going to have some problems like this on less powerful machines. Increasing the hardcoded value could help with this, but is going to slow down the test suite.

Instead, I update the tests to trigger the round end manually from an existing function of the GameEngine module and extend the next round timer to 30 seconds. This should cover the case where the timer has already lapsed before the browser looks for the DOM elements and is ready to continue the test.

  describe "when player has last character alive" do
    setup [:register_user, :create_game_session]

    test "game should no longer show on user dashboard", ctx do
      session =
        ctx.session
        |> sign_in(as: ctx.user)
        |> join_game_and_wait_until_loaded(ctx.join_code, "00:00")

      {:ok, _} = GameEngine.end_round(ctx.game_id)

      session
      |> assert_text("YOU WON!")
      |> visit("/")
      |> assert_text("Welcome User")
      |> refute_has(Query.text("Games in Progress"))
    end
  end

Stopping game session processes

Concluded games no longer show on user dashboards, but the game session processes are still running in memory. I need to terminate these sessions in a way that they won’t be restarted by the dynamic supervisor.

I create a new test scenario in the GameEngineTest module to check the process is stopped after a game has concluded.

defmodule Minotaur.GameEngineTest do
  # …

  describe "round ends with 1 PC alive" do
    setup [:create_game_near_end, :start_game_session, :p1_wins]

    test "game status is concluded", %{game: game} do
      assert :concluded == game.status
    end

    test "game session process is stopped", %{game: game} do
      assert {:error, :not_alive} = GameEngine.get_game(game.id)
    end
  end

  defp create_game_near_end(_) do
    players = [
      p1 = player_fixture(%{user_id: user_fixture().id}),
      p2 = player_fixture(%{user_id: user_fixture().id})
    ]

    game =
      game_fixture(players: players)
      |> put_in([:world, :player_characters, p1.id, :health], 1)
      |> put_in([:world, :player_characters, p2.id, :health], 1)

    [game: game, p1: p1, p2: p2]
  end

  defp start_game_session(ctx) do
    join_code = "AABB"
    {:ok, _pid} = GameEngine.continue_game(join_code, ctx.game)

    [join_code: join_code]
  end

  defp p1_wins(%{game: game, p1: p1, p2: p2}) do
    {:ok, game} = GameEngine.register_attack(game.id, p1.user_id, p2.id)
    {:ok, game} = GameEngine.end_round(game.id)

    [game: game]
  end
end

The test case checking for the updated game status is already passing since this behavior was previously added. The second test case that checks for the process to not be alive is still failing so I’ll need to update the session server code to make it pass. I’ll leave this here for today, but I have a good point to jump back into next time with this test already in place.