One thing I realized while building logic to handle move events on the front end is the to and from coordinates in the event details are referencing the actual hex grid coordinates while all other coordinate references on the front end are using coordinates relative to the player’s current position. An early design decision I made was to hide any absolute coordinate values from the user so they don’t know exactly how far away their character is from the center or edges of the grid. Before jumping into the front end logic to complete the events log UI component, I’ll modify the event data to use relative coordinates before being sent to the frontend.

Since the move event coordinates will be relative to the player’s current position (0,0), the event data being sent to the frontend can be simplified by removing any redundant reference to the current coordinate. Instead of sending the origin and destination coordinates, the direction relevant for the event can be sent. Events for a PC entering a hex only need to contain the direction or vector from which the PC entered. Similarly, events for a PC leaving a hex only need the vector to the destination hex.

I update the formatter module for these two events to replace the coordinates with a vector.

defmodule MinotaurWeb.GameFormatter do
  # …

  def format(%PCEnteredHexEvent{} = event) do
    from_vector = get_vector(event.to, event.from)

    event
    |> Map.from_struct()
    |> Map.drop([:to, :from])
    |> Map.put(:from_vector, from_vector)
    |> Map.put(:event_type, "PC_ENTERED_HEX")
  end

  def format(%PCLeftHexEvent{} = event) do
    to_vector = get_vector(event.from, event.to)

    event
    |> Map.from_struct()
    |> Map.drop([:to, :from])
    |> Map.put(:to_vector, to_vector)
    |> Map.put(:event_type, "PC_LEFT_HEX")
  end

  defp get_vector(%{q: from_q, r: from_r}, %{q: to_q, r: to_r}) do
    %Vector{q: to_q - from_q, r: to_r - from_r}
  end
end

Updating frontend data store

Now that the event records are being delivered in the payload of game-joined and next-round-started events, I need to update the relevant event handlers to convert these records to messages which will be stored in the Alpine store. The events log Alpine component renders event messages based on the list of message objects in the Alpine store.

Starting with the game-joined event handler, I pull out the new events property of the LiveView event payload and convert this data to a list of GameEvent objects. This data conversion will ensure I’m using consistent data structures defined in the frontend app and translates the object keys from snake_case to camelCase at the boundary of the application. I’m not using TypeScript in this project, but I use JSDoc to define custom types.

import Coord from './Coord'

/**
 * @typedef {import('./Coord').Coord} Coord
 */

/**
 * @typedef {Object} GameEvent
 * @property {number} id
 * @property {string} eventType
 */

/**
 * @typedef {GameEvent & {
 *   playerId: number,
 *   round: number,
 *   from: Coord
 * }} PCEnteredHexEvent
 */

/**
 * @typedef {GameEvent & {
 *   playerId: number,
 *   round: number,
 *   to: Coord
 * }} PCLeftHexEvent
 */

/**
 * @typedef {GameEvent[]} GameEvents
 */

/**
 * @param {Object} attrs
 * @param {Number} attrs.id
 * @param {String} attrs.event_type
 * @param {Number} attrs.player_id
 * @param {Number} attrs.round
 * @param {Coord} attrs.from_vector
 * @returns {PCEnteredHexEvent}
 */
export function PCEnteredHexEvent(attrs) {
  return {
    id: attrs.id,
    eventType: attrs.event_type,
    playerId: attrs.player_id,
    round: attrs.round,
    from: Coord(attrs.from_vector),
  }
}

/**
 * @param {Object} attrs
 * @param {Number} attrs.id
 * @param {String} attrs.event_type
 * @param {Number} attrs.player_id
 * @param {Number} attrs.round
 * @param {Coord} attrs.to_vector
 * @returns {PCLeftHexEvent}
 */
export function PCLeftHexEvent(attrs) {
  return {
    id: attrs.id,
    eventType: attrs.event_type,
    playerId: attrs.player_id,
    round: attrs.round,
    to: Coord(attrs.to_vector),
  }
}

/**
 * @param {Object} attrs
 * @returns {GameEvent}
 */
export function GameEvent(attrs) {
  const { event_type: type } = attrs

  if (type === 'PC_ENTERED_HEX') {
    return PCEnteredHexEvent(attrs)
  }

  if (type === 'PC_LEFT_HEX') {
    return PCLeftHexEvent(attrs)
  }

  console.error(`Invalid event type: ${type}`)

  return null
}

/**
 * @param {Object[]} rawEventList
 * @returns {GameEvents}
 */
export default function GameEvents(rawEventList) {
  return rawEventList.map(GameEvent).filter((event) => event !== null)
}

I update the wrapper object for the Alpine store to create a set function for setting the list of event messages that will be rendered when the game-joined event is triggered. I’ll eventually need to add another function to add new events brought in by the next-round-started event, but I’ll hold off on that until the time comes.

// Creates wrapper store object for Alpine store
export default function initializeStore(Alpine) {
  return {
    // …

    eventsLog: {
      set(eventMessages) {
        Alpine.store('eventsLog', eventMessages)
      },
    },
  }
}

I create a new module that will handle the case of taking the new GameEvent objects, converting them to event message objects, and passing this list to a call of the new store.eventsLog.set function. This is where the event message text will be constructed from the details of the GameEvent records. The exported function is a factory function that takes a reference to the data store as an argument. I add some placeholder functions to allow the application to continue working as I build out the remaining logic.

function convertPCEnteredHex(event) {
  // TODO
  return null
}

function convertPCLeftHex(event) {
  // TODO
  return null
}

function convertGameEventToMessage(event) {
  const { eventType: type } = event

  if (type === 'PC_ENTERED_HEX') {
    return convertPCEnteredHex(event)
  }

  if (type === 'PC_LEFT_HEX') {
    return convertPCLeftHex(event)
  }

  return null
}

export default function createSetGameEventsLog({ store }) {
  return (events) => {
    const messages = events
      .map(convertGameEventToMessage)
      .filter((message) => message !== null)

    store.eventsLog.set(messages)
  }
}

Changing event structures

Looking at the feature test I wrote the other day for the events log messages, I see I need to create a message string containing the display name of the player character who moved and the cardinal direction of the relevant hex for the movement. The direction can be derived from the vector in the event, but I don’t have the display name for other players readily at hand from the event data. The player id is included in the event which could be mapped to a player record if it is sent with the LiveView event payload. However, instead of sending a full map of player names and their ids, I could just send the names in the payload instead of the player ids. I don’t see much of a problem having the player ids in the payload since player ids are generated per game session, but I think I prefer sending the names so all relevant data is in the event record.

I update the format functions to take a second argument which is the game struct so I can map the player ids to a display name. Since most of the original event details are being stripped from the event at this point, I replace the return values with new map literals containing the relevant data.

defmodule MinotaurWeb.GameFormatter do
  # …

  def format(%PCEnteredHexEvent{} = event, game) do
    %{
      id: event.id,
      event_type: "PC_ENTERED_HEX",
      pc_name: game.players[event.player_id].display_name,
      round: event.round,
      from_vector: get_vector(event.to, event.from)
    }
  end

  def format(%PCLeftHexEvent{} = event, game) do
    %{
      id: event.id,
      event_type: "PC_LEFT_HEX",
      pc_name: game.players[event.player_id].display_name,
      round: event.round,
      to_vector: get_vector(event.from, event.to)
    }
  end
end

I update the places in the GameLive module that call these format functions so they pass in the game structs. I also update the JavaScript code that converts the LiveView payloads to GameEvent objects so they use pcName instead of playerId. I finish the event message string construction by converting the move vector to a direction string and combining with the player name.

function convertCoordToDirection({ q, r }) {
  if (q === 0 && r === -1) {
    return 'northwest'
  }

  if (q === 1 && r === -1) {
    return 'northeast'
  }

  if (q === -1 && r === 0) {
    return 'west'
  }

  if (q === 1 && r === 0) {
    return 'east'
  }

  if (q === -1 && r === 1) {
    return 'southwest'
  }

  if (q === 0 && r === 1) {
    return 'southwest'
  }

  return null
}

function convertPCEnteredHex(event) {
  const direction = convertCoordToDirection(event.from)

  if (!direction) {
    return null
  }

  return {
    round: event.round,
    text: `${event.pcName} arrived from the ${direction}`,
  }
}

function convertPCLeftHex(event) {
  const direction = convertCoordToDirection(event.to)

  if (!direction) {
    return null
  }

  return {
    round: event.round,
    text: `${event.pcName} moved to the ${direction}`,
  }
}

// …

The events log UI component is now rendering event messages from previous rounds after the game session is joined. The feature test I created previously is now passing which confirms I’ve achieved the desired outcome.

I still need to add events from the next-round-started events to the events log store otherwise the user will need to reload the page to see new events. Another adjustment I should make is to change how the move event message is written when the moved player is the current user. Currently, the user can see their own PCEnteredHexEvent records which should render as a message like “You moved to the northeast.” instead of showing the player name for the user.