Phoenix LiveView

Multiplayer Games & Collaborative editing

with

Two use-cases for LiveView

  • 'snippets' (small self-contained components)
    • Search bar with suggestions
    • Sortable tables
    • Updating some elements real-time
  • Single Page Applications (SPAs)
    • Manage the whole interface

Goals

  • Go beyond the 'Hello World' / 'TodoMVC' / 'Chat'
    • What happens when a LiveView SPA grows? How to keep:
      • comprehensible
      • maintainable
      • extensible
  • Answer/contextualize common LiveView usage question
    \(\rightarrow\) Build a game!

Why do I talk about this?

Marten / Qqwy

LiveView-related projects

  • Planga: Chat Service (Phoenix Channels)
  • Powerchainger: Smart Energy measurement device (Nerves, LiveView)
  • CyanView: Live video stream color-correction device (LiveView + MQTT)

When use LiveView for an SPA?

multiple users/devices/browser tabs need to see & edit the same state in real-time

"collaborative editing"

What about games?

  • Multiple players manipulating the same 'world state'

Global Game Jam

  • 48hrs
    • Elixir/Phoenix Channels for controls,
    • Unity for graphics on the TV screen

What does LiveView do?

Browser

 

  • connect to server
     
  • send events
  • listen for responses

     
  • interpret/render responses
  • mutate DOM
     

Server

  • listen for events
     
  • manage sessions

     
  • handle events
  • send responses

     

'Vanilla' web-app

Browser

 

  • connect to server
     
  • send events
  • listen for responses

     
  • interpret/render responses
  • mutate DOM
     
  • handle disconnects/reconnects
  • handle error responses

Server

  • listen for events
     
  • manage sessions

     
  • handle events
  • send responses


     
  • handle disconnects/reconnects
  • handle malformed events

'Vanilla' web-app

Browser

 

  • connect to server
     
  • send events
  • listen for responses

     
  • interpret/render responses
  • mutate DOM
     
  • handle disconnects/reconnects
  • handle error responses

Server

  • listen for events
     
  • manage sessions

     
  • handle events
  • send responses


     
  • handle disconnects/reconnects
  • handle malformed events

'Vanilla' web-app

Browser

 

  • connect to server
     
  • send events
  • listen for responses


     
  • mutate DOM
     
  • handle disconnects/reconnects
  • handle error responses

Server

  • listen for events
     
  • manage sessions

     
  • handle events
  • send responses
  • interpret/render responses

     
  • handle disconnects/reconnects
  • handle malformed events

LiveView

  • (simplified) Functional Reactive Programming
    • 'The Elm Architecture'
      • Essentially what all GenServers do

handle events & render responses

interpret  event

interpret response

  • 95% of JavaScript & communication logic abstracted away
  • structured event handling
    • testable
    • matches other OTP processes

What does LiveView do?

LiveView's limits?

  • It requires good connectivity
    • Not offline
    • Not high latency
      front-end Elixir code is being worked on, c.f. Lumen
  • Drive UI that does not work with DOM-diffing
    • Animations (use CSS)
    • Canvas, WebGL, etc.
  • Some things still missing, it's still in beta

multi-user LiveView architecture

Let's build a game!

GenServer?

Database?

Message Queue?

Game: AgarEx

Based on Agar.io:

  • Players are cells
    • realtime movement
    • eating 'agar' & 'smaller' players to become bigger
    • size before being eaten = score
    • rounds reset every couple minutes

Game Server

  • receive player messages using call/cast elided below
  • send updated state using Phoenix.PubSub
  • update game state 30 times per second
defmodule Agarex.Game.Server do
  alias Agarex.Game.State
  use GenServer

  @fps 30
  @tick_ms div(1000, @fps)

  def start_link(_) do
    GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  end

  @impl true
  def init(_) do
    Process.send_after(self(), :tick, @tick_ms)
    {:ok, %{time: System.monotonic_time(), game_state: State.new()}}
  end

  # ...

  @impl true
  def handle_info(:tick, state) do
    Process.send_after(self(), :tick, @tick_ms)
    state = tick(state)
    {:noreply, state}
  end


  defp tick(state) do
    new_time = System.monotonic_time()
    delta_time = new_time - state.time

    game_state = State.tick(state.game_state, delta_time)
    Phoenix.PubSub.broadcast(Agarex.PubSub, "game/tick", {"game/tick", game_state})

    %{state | time: new_time, game_state: game_state}
  end
end

Managing State

(both in LiveView processes and elsewhere)

  1. nested tree of structs ('product & sum types')
  2. nested event handling
  3. separate state changing from (side-)effects

1. Nested Tree of Structs

defmodule Agarex.Game.Server.State.World do
  defstruct [:players, :agar]

  def new() do
    %__MODULE__{
      players: Arrays.new(),
      agar: Arrays.new(),
    }
  end
  
  # ...
end
defmodule Agarex.Game.Server.State.World.Player do
  defstruct [:name, :size, :position, :velocity, :alive?]

  def new(name) do
    %__MODULE__{
      name: name,
      size: 1,
      position: {10, 20},
      velocity: {0, 0},
      alive?: true,
    }
  end
  
  # ...
end

1. Nested Tree of Structs

defmodule Agarex.Game.LocalState do
  alias __MODULE__.Controls

  defstruct [:controls, :game_state, :player_id]
  def new(player_id) do
    %__MODULE__{controls: Controls.new(), game_state: nil, player_id: player_id}
  end
  
  # ...
end
defmodule Agarex.Game.LocalState.Controls do
  defstruct up: false, down: false, left: false, right: false
  def new() do
    %__MODULE__{}
  end
  
  def to_velocity(c) do
    horizontal = bool_to_int(c.right) - bool_to_int(c.left)
    vertical = bool_to_int(c.down) - bool_to_int(c.up)
    {horizontal, vertical}
  end

  # ...
end

2. Nested Event Handling

  defmodule AgarexWeb.GameLive do
  # ...
  
  def handle_event(event, params, socket) do
    do_handle_event(event, params, socket)
  end

  def handle_info({event, params}, socket) do
    do_handle_event(event, params, socket)
  end

  defp do_handle_event(raw_event, params, socket) do
    event = normalize_event(raw_event)
    new_local = Agarex.Game.LocalState.handle_event(event, params, socket.assigns.local)
    socket = assign(socket, :local, new_local)

    {:noreply, socket}
  end

  defp normalize_event(event) do
    String.split(event, "/")
  end
end

2. Nested Event Handling

defmodule Agarex.Game.LocalState do
# ...
  def handle_event(event, params, struct) do
    case event do
      ["controls" | rest] ->
        struct = update_in(struct.controls, &Controls.handle_event(rest, params, &1))
        Controls.broadcast_velocity(struct.controls, struct.player_id)
      ["game" , "tick"] ->
        put_in(struct.game_state, params)
    end
  end
end
  • "controls/key_up"
  • "controls/key_down"
  • "game/tick"
  • etc.

Routing, outside-in

defmodule Agarex.Game.LocalState.Controls do
  # ...
  def handle_event(event, params, struct) do
    case event do
      ["key_up"] ->
        key_up(struct, params["key"])
      ["key_down"] ->
        key_down(struct, params["key"])
    end
  end
  # ...
end

3. Separate state from effects

Goal: maintainability

  • Rather than performing side-effect immediately, build declarative (symbolic) description(s)...
  • ... and execute at the fringe
  • When (nested) handling events,
    return `{state, [list, of, effects]}`

3. Separate state from effects

defmodule Agarex.Effect do
  def effectful_update_in(struct, access_list, function) do
    {result, effects} = 
      struct
      |> Access.get(access_list)
      |> function.()
    struct = put_in(struct, access_list, result)
    {struct, effects}
  end
  
  # arity-2 variant elided
  
end
defmodule Agarex.Effect.BroadcastPlayerMovement do
  defstruct [:player_id, :controls]
  
  def new(player_id, controls) do
    %__MODULE__{player_id: player_id, controls: controls}
  end
end
defmodule Agarex.Game.LocalState do
  # ...
  def handle_event(event, params, struct) do
    case event do
      ["controls" | rest] ->
        struct = update_in(struct.controls, &Controls.handle_event(rest, params, &1))
        Controls.broadcast_velocity(struct.controls, struct.player_id)
      ["game" , "tick"] ->
        put_in(struct.game_state, params)
      end
    end
  end
  # ...
end

3. Separate state from effects

defmodule Agarex.Game.LocalState do
  # ...
  def handle_event(event, params, struct) do
    case event do
      ["controls" | rest] ->
        {struct, effects} = 
          Effect.effectful_update_in(struct.controls, &Controls.handle_event(rest, params, &1))
        {
          struct, 
          [Effect.BroadcastPlayerVelocity.new(struct.controls, struct.player_id)] ++ effects
        }
      ["game" , "tick"] ->
        struct = put_in(struct.game_state, params)
        {struct, []}
      end
    end
  end
  # ...
end

3. Separate state from effects

module AgarexWeb.EffectInterpreter do
  def run(effect, socket) do
    case effect do
      %Agarex.Effect.BroadcastPlayerVelocity{} ->
        Phoenix.PubSub.broadcast(
          #...
        )
    end
  end
end
  • Plain data, no protocol:
    • Use a different interpreter for tests
  • Very explicit what happens during state changes
  • Very flexible in where/what/how effects are handled

3. Separate state from effects

(4. Contracts & property testing)

  • Contracts: Prevent front-end from sending non-recognized events
  • Property Testing: Check whether state can handle any (sequence of) valid event(s) without crashing
  • C.f. the TypeCheck library https://hex.pm/packages/type_check

Stateful vs Stateless?

  • Stateless: DRY, different levels of detail
  • Stateful: Abstract details
    examples:
    • keypresses \(\rightarrow\) player movement
    • form validations \(\rightarrow\) submitted values

LiveView vs LiveComponent?

  • LiveView = extra process:
    • Fault-tolerance
    • separate server-side events
  • LiveComponent:
    • everything else

"Can we visualize software complexity?", Rafał Studnicki!

Other things in AgarEx

which are too much to cover too this presentation

  • Rendering using SVG + CSS animations
  • More gamedev-specific tricks (2D collisions, etc)

Other Resources

Thank you!

Time for questions

Multiplayer Games & Collaborative Editing with Phoenix LiveView

By qqwy

Multiplayer Games & Collaborative Editing with Phoenix LiveView

  • 1,361