Saving Game State Each Round
Automatic game session recovery
To finish out the feature of automatically resuming games in progress during a cold start, I call the newly created resume_active_sessions
function within the Application
module’s start
callback just after the root supervision tree is started.
def start(_type, _args) do
children = [
# …
]
opts = [strategy: :one_for_one, name: Minotaur.Supervisor]
application_start_result = Supervisor.start_link(children, opts)
GameEngine.resume_active_sessions()
application_start_result
end
Update game state every round
Game state can be recovered after a full cluster crash, but the stored game state is never updated after the game session process is started. The next feature to build is updating state at the end of each game round so minimal game progress is lost after being recovered.
I add a new test case for the behavior I want to achieve.
describe "a game session is stopped after applying end of round changes" do
setup [:start_game, :play_rounds, :stop_game]
test "game is resumed with latest updated state", ctx do
:ok = GameEngine.resume_active_sessions()
assert {:ok, game} = GameEngine.get_game(ctx.game.id)
assert game.id == ctx.updated_game.id
assert game.round == ctx.updated_game.round
assert game.players == ctx.updated_game.players
assert game.world == ctx.updated_game.world
end
end
defp play_rounds(%{game: game, user: user}) do
GameEngine.end_round(game.id)
GameEngine.register_move(game.id, user.id, %Vector{q: -1, r: 0})
{:ok, updated_game} = GameEngine.end_round(game.id)
[updated_game: updated_game]
end
I expect this test to fail, but it actually passes. This is exactly why it is good to start with a failing test so you can catch the unexpected false positives. I recognize the same gotcha that I ran into with the previous tests in this module where the in-memory state recovery is taking precedence over the state being pulled from the database. I update the test setup functions to clear the stashed state after the game process is shut down and the test is now failing as expected.
I update the game session GenServer module to save the latest game state version when the game round ends.
I can reuse the update_summary_record
function I defined previously to store the updated game state.
defp end_round(game, join_code) do
timer_end_time = get_timer_end_time(@round_time_ms)
{:ok, game} = Session.end_round(game, timer_end_time)
notify_next_round_started(game)
set_round_timer(game, @round_time_ms)
update_summary_record(game, join_code)
{:ok, game}
end
The test is not passing which isn’t too unexpected since update_summary_record
is doing an insert where it should now be performing a an “upsert” for existing summary records.
GameSessionSummary
records are also not unique by the game id field so the current logic is creating a new summary record every time the round ends for each game.
I add a migration to create this unique constraint and also add an index for query filters on game_status
such as when running the new resume_active_sessions
function.
defmodule Minotaur.Repo.Migrations.AddUniqueIndexGameSessionSummary do
use Ecto.Migration
def change do
create unique_index(:game_session_summaries, [:game_id])
create index(:game_session_summaries, [:game_status])
end
end
After running the migration, the test raises an error from Ecto trying to insert summary records with an existing game id.
I pass options for resolving conflicts to the Repo.insert
function call.
defp update_summary_record(game, join_code) do
attrs = %{
game_id: game.id,
join_code: join_code,
game_status: :active,
latest_round: game.round,
game_state: game,
player_summaries:
game.players
|> Enum.map(fn {_, player} ->
%PlayerGameSessionSummary{
user_id: player.user_id,
player_status: player.status
}
end)
}
%GameSessionSummary{}
|> GameSessionSummary.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: [:game_id]
)
end
This should update all fields for the GameSessionSummary
record in the database except for :id
and inserted_at
.
However, a new problem came from this change.
The associated PlayerGameSessionSummary
records are being inserted as new records for their respective table which has a unique index on the game session summary foreign key id and the user foreign key id.
I don’t believe there is any way to resolve the conflicting associated records with a single insert statement so I break the nested records into a separate statement wrapped in a transaction.
defp update_summary_record(game, join_code) do
Repo.transaction(fn ->
attrs = %{
game_id: game.id,
join_code: join_code,
game_status: :active,
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
defp insert_player_summary_records(game, game_session_summary) do
placeholders = %{
game_session_id: game_session_summary.id,
timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
}
player_summaries_attrs =
game.players
|> Enum.map(fn {_, player} ->
%{
game_session_id: {:placeholder, :game_session_id},
user_id: player.user_id,
player_status: player.status,
inserted_at: {:placeholder, :timestamp},
updated_at: {:placeholder, :timestamp}
}
end)
Repo.insert_all(
PlayerGameSessionSummary,
player_summaries_attrs,
placeholders: placeholders,
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: [:game_session_id, :user_id]
)
end
The test is passing which means the updated game state is being pulled out of the database and used to initialize a new game session process.
Default values for custom schema types
It appears I can no longer set a default empty map for the embedded schemas that use a custom Ecto.Type
which makes the World
struct a little more brittle to work with when every place that creates a new struct needs to provide a default value.
I create a simple function to initialize a World
struct and fill in the default values, then update all the references in code that create worlds with this new function call.
defmodule Minotaur.GameEngine.World do
use Ecto.Schema
use StructAccess
alias Minotaur.GameEngine.EctoTypes.{DeadPlayerCharactersMap, GridMap, PlayerCharactersMap}
@primary_key false
embedded_schema do
field :grid, GridMap
field :player_characters, PlayerCharactersMap
field :dead_characters, DeadPlayerCharactersMap
end
def new(attrs \\ []) do
attrs =
Keyword.validate!(attrs,
grid: %{},
player_characters: %{},
dead_characters: %{}
)
struct(__MODULE__, attrs)
end
end