Building an event-sourced game with Phoenix Liveview: Decrementing the timer

| 4 min read

One thing that always comes with challenges in applications is time. In Docteur Pilule, we have an instance of time in the form of a timer: a round is 40 seconds long.

When I started coding the game, I wanted to display the decrementing counter, changing every second, to players.

In this article, we'll see that thanks to Elixir, Liveview, and the design choices made so far, it's pretty easy to do.

Sending a message to self

Here I propose a solution based on what Greg Young describes as "sending a message to self".

The idea is that a component passes a message to a delivery mechanism with a date it would like to receive the message and goes back to its own business. When it's time, the delivery mechanism sends the message to the component to be handled as any other message.

Future message container

We've seen in a previous article that every action returns an ActionResult structure. The ActionResult module is the mean of communication between the game rules and the game server. It can be extended to handle future messages.

First, we add a new key in the structure, scheduled_messages. That key points to a list of tuples containing a delay in milliseconds and a message.

defmodule ActionResult do

  defstruct [
    events: [],
    scheduled_messages: [],
    error: nil
  ]

We can then complete the module with functions helping us deal with combining several ActionResults that may contain scheduled messages.

def new(events, scheduled_messages), do:
  %ActionResult{events: events, scheduled_messages: scheduled_messages}

def add(%ActionResult{events: events, scheduled_messages: scheduled_messages}, %ActionResult{events: other_events, scheduled_messages: other_scheduled_messages}), do:
  %ActionResult{events: events ++ other_events, scheduled_messages: scheduled_messages ++ other_scheduled_messages}

def add(%ActionResult{events: events, scheduled_messages: scheduled_messages}, other_events) when is_list(other_events), do:
  %ActionResult{events: events ++ other_events, scheduled_messages: scheduled_messages}

Decrementing the timer

Choosing to start, decrement, or stop the timer is a game rule and thus resides in the application's game logic part. As seen previously, each concept in the game gets its module with functions acting as commands and the apply_event callback returning a new state when an event is applied.

Even if the Timer module needs to deal with scheduled messages, it is no different than other modules.

defmodule Timer do
 # ...
  @round_time 40
  @one_second :timer.seconds(1)

  def start_time(), do:
    ActionResult.new(
      [
        RoundTimeStarted.with(
          remaining_time: @round_time
        ),
      ],
      [{@one_second, %Tick{}}]
    )

  def decrement_time(1), do:
    ActionResult.new([%RoundEnded{}])

  def decrement_time(current_time) do
    time = current_time - 1
    ActionResult.new(
      [RoundTimeTicked.with(remaining_time: time)],
      [{@one_second, %Tick{}}]
    )
  end

  def apply_event(_time, %RoundTimeTicked{data: %{remaining_time: remaining_time}}), do: remaining_time

  def apply_event(_time, %RoundTimeStarted{data: %{remaining_time: remaining_time}}), do: remaining_time

end

The module defines two module attributes, @round_time and @one_second, respectively describing the round duration in seconds and one second expressed in milliseconds.

The start_time function returns an ActionResult containing a RoundStarted event with a remaining time set to @round_time. The ActionResult also contains a scheduled message, %Tick{}, programmed to be received in one second.

Similarly, decrement_time decrements the time, produces a RoundTimeTicked event, and schedules a %Tick{} for one second later.

If the timer's value is 1, the first clause of decrements_time is selected. It produces an %RoundEnded{} event and doesn't schedule a %Tick{} as there is no need to decrement the timer anymore.

apply_events for %RoundTimeTicked{} and %RoundTimeStarted return the remaining time contained in the event as the new timer state.

The InRound module, which encapsulates all game behaviors during a round, stays simple.

When a player starts a round, the timer starts, and a word is distributed.

def start_round(game), do:
  ActionResult.new()
  |> ActionResult.add(Timer.start_time())
  |> ActionResult.add(Dictionary.give_word_to_player(game.dictionary, Teams.current_player(game.teams)))

When a %Tick{} message is received, the timer is decremented.

def handle(%InRound{} = game, %Tick{}) do
  Timer.decrement_time(game.timer)
end

The application of an event related to the timer is delegated to the Timer module. Theapply_event` function returns the new timer state.

def apply_event(%InRound{} = state, %RoundTimeStarted{} = e), do:
  %InRound{state | timer: Timer.apply_event(state.timer, e)}

def apply_event(%InRound{} = state, %RoundTimeTicked{} = e), do:
  %InRound{state | timer: Timer.apply_event(state.timer, e)}

The game logic now deals with the timer: it can decide when the timer must be started, schedule the timer to be decremented a second later, and decrement and stop it.

This is everything to know about changes in the game logic. We now need to talk about the delivery mechanism for the scheduled message.

The GameServer as scheduled messages delivery mechanism

The part title totally spoiled it: we're going to use the GameServer as our delivery mechanism for scheduled messages.

Using Process.send_after, Elixir makes it very easy to send to a process a message in the future.

Let's add a private schedule_messages function in the module:

defp schedule_messages(%ActionResult{scheduled_messages: scheduled_messages} = result) do
  for {delay, message} <- scheduled_messages, do:
    Process.send_after(self(), {:scheduled_message, message}, delay)

  result
end

This function looks for the scheduled messages inside an ActionResult, and for each one of them sends to self(), the current GameServer process, a tuple message {:scheduled_message, message} delayed by the amount of time requested by the game logic. It conveniently returns the ActionResult, which means we can add this function to a chain of piped functions.

Once the delay is over, the GameServer process handle_info callback is called with the message.

We can create a clause for handle_info and let it handle the message as a command and then store the state.

def handle_info({:scheduled_message, message}, state), do:
  new_state = handle_command(message, state)
  {:noreply, new_state}

We've already seen the handle_command function. This is the function we call when the GameServer receives a command. As a reminder, the function dispatches the command to the game logic, broadcast events for the view, and collect events to store them in the state.

It's the perfect place to add schedule_message. Adding it here means that messages are scheduled when a command is handled and when a scheduled message is handled, precisely what we want. The first case takes care of starting the timer to tick when the round begins, and the second case deals with scheduling the next tick when a tick happens.

defp handle_command(command, state), do:
  command
    |> dispatch_command(state)
    |> dispatch_events(state)
    |> schedule_messages()
    |> build_new_state(state)

With this in place, the game logic can express its need to be notified later to take decisions without being coupled to any infrastructure concerns. As promised, it required only a few code additions, no massive rework, and keeps the application well organized.