Game Event Log

- 5 mins read

The next in-game feature I want to build is a text log that shows a player all resolved events to which they have visibility. This will involve keeping a list of events on the backend and adding a new UI element to the frontend to display events as text.

I’ll need to think about how events will be tracked for each game in a way that can be filtered by each player that has visibility to the event. Some events will have a global visibility such as a new round has started or a player character was killed. Other events are specific to a location and only players whose characters are in the location should have visibility to the event. I can keep a list of global events with a separate hash table of player specific event lists and build the full event log for a player from both lists.

Another use case for the event log is for an admin to be able to list out all events sequentially across all players for a game session. To more easily support this case, I might store all events in a hash table with a unique identifier and have the player events hash table store a list of event IDs to which the player has visibility.

Let the tests drive

I will start by writing a test that asserts an event is created when a player character moved after a registered move is process at the end of the round. All of the events will occur at the end of the round and the event creation will occur when Session.end_round is called to update the game state. The tests for this function will continue to grow as more features are added to the game, so I will first split out the movement related tests to a separate module. Splitting tests into multiple modules can help organize the test code, but also potentially improves test performance as each module that is tagged with :async can run tests in parallel with asynchronous tests in other modules.

The existing movement tests for the end_round/2 function that I split into a separate module are simply asserting the player character positions are updated to the correct coordinate on the hex grid.

defmodule Minotaur.GameEngine.Session.EndRoundPlayerMoveTest do
  use ExUnit.Case, async: true

  import Minotaur.GameEngineFixtures

  alias Minotaur.GameEngine.{Coord, Player, Session, Vector}

  describe "player registered a move action" do
    setup [:new_game, :move_players]

    test "should reset registered_actions to empty map", %{game: game} do
      {:ok, game_round2} = Session.end_round(game, nil)

      assert %{} == game_round2.registered_actions
    end

    test "should update position of each moved character", context do
      %{game: game, player1: p1, player2: p2} = context

      {:ok, round2_game} = Session.end_round(game, nil)
      %{player_characters: characters} = round2_game.world

      assert %Coord{q: 0, r: 0} == characters[p1.id][:position]
      assert %Coord{q: -1, r: 1} == characters[p2.id][:position]
    end
  end

  defp move_players(%{game: game, player1: p1, player2: p2}) do
    {:ok, game} = Session.register_player_move(game, p1.user_id, %Vector{q: 1, r: 0})
    {:ok, game} = Session.register_player_move(game, p2.user_id, %Vector{q: 0, r: 1})

    [game: game]
  end

  defp new_game(_) do
    player1 = %Player{id: 3, display_name: "Player1", user_id: 111}
    player2 = %Player{id: 6, display_name: "Player2", user_id: 222}
    coord = %Coord{q: -1, r: 0}

    game =
      game_fixture(
        players: [player1, player2],
        pc_coords: %{player1.id => coord, player2.id => coord}
      )

    [game: game, player1: player1, player2: player2]
  end
end

I add a new test assertion for the existing scenario of a player registering a move for the round. The test checks that an event is created for each player character movement. The test does not compile because there is not yet any concept of events in the game state or session logic.

defmodule Minotaur.GameEngine.Session.EndRoundPlayerMoveTest do
  # ...

  describe "player registered a move action" do
    setup [:new_game, :move_players]

    # ...

    test "creates event for each moved character", ctx do
      %{player1: %{id: p1_id}, player2: %{id: p2_id}} = ctx

      {:ok, game} = Session.end_round(ctx.game, nil)
      events = game.events_log.events
      {_, p1_event} = Enum.find(events, fn {_, event} -> event[:player_id] == ctx.player1.id end)
      {_, p2_event} = Enum.find(events, fn {_, event} -> event[:player_id] == ctx.player2.id end)

      assert %PCMovedEvent{
               player_id: ^p1_id,
               from: %Coord{q: -1, r: 0},
               to: %Coord{q: 0, r: 0}
             } = p1_event

      assert %PCMovedEvent{
               player_id: ^p2_id,
               from: %Coord{q: -1, r: 0},
               to: %Coord{q: -1, r: 1}
             } = p2_event
    end
  end
end

I create a new PCMovedEvent struct which allows the test to compile.

defmodule Minotaur.GameEngine.Events.PCMovedEvent do
  defstruct [:id, :player_id, :to, :from]
end

I then add an :events_log field to the Game struct definition.

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 :events_log, EventsLog
    embeds_one :world, World
  end

  def new(attrs \\ []) do
    defaults = [
      players: %{},
      registered_actions: %{},
      events_log: %EventsLog{}
    ]

    attrs = Keyword.merge(defaults, attrs)

    struct(__MODULE__, attrs)
  end
end
defmodule Minotaur.GameEngine.EventsLog do
  use Ecto.Schema

  embedded_schema do
    field :events, :map, default: %{}
  end
end

Next, I update the logic for end_round/2 so the events are created for each moved player character. Registered actions are applied within the resolve_end_of_round_world_changes private function which is where event creation can be added. I need to refactor a few nested functions as there is partial game state data being piped to multiple private functions which will now need the full game state to update events log. Once the full game state is available in all the inner functions, I update the apply_action for the matching MoveAction argument so it creates events for each applied action.

  defp apply_action(%MoveAction{} = action, game) do
    %{player_id: player_id, vector: vector} = action
    %{position: original_position} = game.world.player_characters[player_id]
    new_position = Worlds.apply_vector(original_position, vector)
    event = %PCMovedEvent{player_id: player_id, from: original_position, to: new_position}

    game
    |> put_in([:world, :player_characters, player_id, :position], new_position)
    |> save_event(event)
  end

  defp save_event(game, event) do
    event_id = map_size(game.events_log.events)
    event = %{event | id: event_id}

    put_in(game, [:events_log, :events, event_id], event)
  end

save_event is a separate function responsible for generating the event id and updating the game struct with the new event. I will likely have to change this logic as I add more event types and need to reference the ids in the player visible event map, but this is a good starting point.