Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Building Multiplayer Games with Phoenix LiveView

Dorian Karter
September 03, 2020

Building Multiplayer Games with Phoenix LiveView

Slides from a talk I gave at ElixirConf 2020. In this talk I discuss the architecture and different components involved in building a collaborative real-time application.

Dorian Karter

September 03, 2020
Tweet

More Decks by Dorian Karter

Other Decks in Programming

Transcript

  1. Goal ๏ Demonstrate a real-life LiveView usage. ๏ Basic game

    server architecture & patterns. ๏ Not just for games! And perfect for today's world. ๏ Share my experience and what I learned. 2/67 — Dorian Karter | Boulevard | ElixirConf 2020
  2. TOC ๏ Story + Motivation ๏ Architecture (OTP, Functional Core,

    LiveView) ๏ Garbage Collector for game servers ๏ Recovering from errors ๏ Managing sessions and restoring state ๏ LiveView Testing ๏ Deployment 3/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Joshua Bartell
  3. ! Dorian Karter Sr. Software Engineer @ Boulevard Organizer @

    ChicagoElixir Meetup 4/67 — Dorian Karter | Boulevard | ElixirConf 2020
  4. March 2020 7/67 — Dorian Karter | Boulevard | ElixirConf

    2020 | Photo by Fusion Medical Animation
  5. Solution: Build Something Cool with LiveView 11/67 — Dorian Karter

    | Boulevard | ElixirConf 2020 | Photo by Júnior Ferreira
  6. What this is ๏ Emulation of a board game ๏

    Fancy collaborative web-app What this is not! ๏ 2D game ๏ Full implementation of all possible game rules 15/67 — Dorian Karter | Boulevard | ElixirConf 2020
  7. Supervision Tree children = [ {Phoenix.PubSub, name: KingOfTokyo.PubSub}, KingOfTokyoWeb.Endpoint, {Registry,

    keys: :unique, name: KingOfTokyo.GameRegistry}, KingOfTokyoWeb.Presence, KingOfTokyo.GameSupervisor, KingOfTokyo.GameGarbageCollector, KingOfTokyoWeb.Telemetry ] 20/67 — Dorian Karter | Boulevard | ElixirConf 2020
  8. Functional Core The GameServer is fairly simple - it does

    not perform logic. It delegates updates to a functional core. defmodule KingOfTokyo.Game do alias KingOfTokyo.ChatMessage alias KingOfTokyo.Dice alias KingOfTokyo.GameCode alias KingOfTokyo.Player defstruct chat_messages: [], code: nil, dice_state: %Dice{}, players: [], tokyo_bay_player_id: nil, tokyo_city_player_id: nil end 21/67 — Dorian Karter | Boulevard | ElixirConf 2020
  9. defmodule MyAppWeb.CounterLive do use Phoenix.LiveView def handle_event("inc", _, socket) do

    {:noreply, update(socket, :val, &(&1 + 1))} end def handle_event("dec", _, socket) do {:noreply, update(socket, :val, &(&1 - 1))} end def render(assigns) do ~L""" <div> <h1>The count is: <%= @val %></h1> <button phx-click="dec">-</button> <button phx-click="inc">+</button> </div> """ end def mount(_session, socket) do {:ok, assign(socket, :val, 0)} end end 22/67 — Dorian Karter | Boulevard | ElixirConf 2020
  10. Player Groups DynamicSupervisor & GenServer 25/67 — Dorian Karter |

    Boulevard | ElixirConf 2020 | Photo by Fabrizio Frigeni
  11. GameSupervisor.start_game defmodule KingOfTokyo.GameSupervisor do use DynamicSupervisor def start_game(%GameCode{} = code)

    do child_spec = %{ id: GameServer, start: {GameServer, :start_link, [code]}, restart: :transient } DynamicSupervisor.start_child(__MODULE__, child_spec) end end 26/67 — Dorian Karter | Boulevard | ElixirConf 2020
  12. Registering a Game Server def start_link(%GameCode{} = code) do GenServer.start(__MODULE__,

    code, name: via_tuple(code.game_id)) end defp via_tuple(game_id) do {:via, Registry, {KingOfTokyo.GameRegistry, game_id}} end 28/67 — Dorian Karter | Boulevard | ElixirConf 2020
  13. Finding a Game Server by Code def game_pid(game_id) do game_id

    |> via_tuple() |> GenServer.whereis() end defp call_by_name(game_id, command) do case game_pid(game_id) do game_pid when is_pid(game_pid) -> GenServer.call(game_pid, command) nil -> {:error, :game_not_found} end end 29/67 — Dorian Karter | Boulevard | ElixirConf 2020
  14. Phoenix Presence Store only user_id as the key and no

    game data in metas def mount(_params, session, socket) do socket = with %{"game_id" => game_id, "player_id" => player_id} <- session, {:ok, game} <- GameServer.get_game(game_id), {:ok, player} <- GameServer.get_player_by_id(game_id, player_id), {:ok, _} <- Presence.track(self(), game_id, player_id, %{}) do #... end #... end 31/67 — Dorian Karter | Boulevard | ElixirConf 2020
  15. defmodule KingOfTokyoWeb.Presence do use Phoenix.Presence, otp_app: :king_of_tokyo, pubsub_server: KingOfTokyo.PubSub def

    fetch(topic, presences) do {:ok, players} = KingOfTokyo.GameServer.list_players(topic) Enum.reduce(players, presences, fn %{id: id} = player, acc -> case acc[id] do %{metas: _} -> put_in(acc, [id, :player], player) _ -> acc end end) end end 32/67 — Dorian Karter | Boulevard | ElixirConf 2020
  16. Presence: In GameLive def handle_info(%{event: :players_updated, payload: _}, socket) do

    %{game: game} = socket.assigns game_id = game_id(socket) players = game_id |> Presence.list() |> Enum.map(fn {_k, %{player: player}} -> player end) {:noreply, assign(socket, game: %{game | players: players})} end 33/67 — Dorian Karter | Boulevard | ElixirConf 2020
  17. Pushing Updates Phoenix PubSub 34/67 — Dorian Karter | Boulevard

    | ElixirConf 2020 | Photo by Mathyas Kurmann
  18. Phoenix PubSub Subscribe on mount def mount(_params, session, socket) do

    socket = with %{"game_id" => game_id, "player_id" => player_id} <- session, {:ok, game} <- GameServer.get_game(game_id), {:ok, player} <- GameServer.get_player_by_id(game_id, player_id), {:ok, _} <- Presence.track(self(), game_id, player_id, %{}), :ok <- Phoenix.PubSub.subscribe(KingOfTokyo.PubSub, game_id) do #... end #... end 35/67 — Dorian Karter | Boulevard | ElixirConf 2020
  19. Phoenix PubSub Broadcast Updates defp broadcast_dice_updated!(game_id, dice_state) do broadcast!(game_id, :dice_updated,

    dice_state) end defp broadcast!(game_id, event, payload \\ %{}) do Phoenix.PubSub.broadcast!(KingOfTokyo.PubSub, game_id, %{event: event, payload: payload}) end 36/67 — Dorian Karter | Boulevard | ElixirConf 2020
  20. Phoenix PubSub Handle Broadcasts in LiveView (where subscribed) def handle_info(%{event:

    :dice_updated, payload: dice_state}, socket) do %{game: game} = socket.assigns {:noreply, assign(socket, game: %{game | dice_state: dice_state})} end 37/67 — Dorian Karter | Boulevard | ElixirConf 2020
  21. PubSub + Presence def handle_info(%{event: "presence_diff", payload: _}, socket) do

    socket |> game_id() |> GameServer.broadcast!(:players_updated) {:noreply, socket} end 38/67 — Dorian Karter | Boulevard | ElixirConf 2020
  22. GarbageCollector - GameServer def presence_player_ids(game_id) do game_id |> KingOfTokyoWeb.Presence.list() |>

    Enum.map(fn {player_id, _} -> player_id end) end 40/67 — Dorian Karter | Boulevard | ElixirConf 2020
  23. GameGarbageCollector (GenServer) @garbage_collection_interval :timer.minutes(2) def init([]) do {:ok, timer} =

    :timer.send_interval(@garbage_collection_interval, :garbage_collect) {:ok, %{garbage_collector_timer: timer}} end 41/67 — Dorian Karter | Boulevard | ElixirConf 2020
  24. GameGarbageCollector (GenServer) - Cont. def handle_info(:garbage_collect, state) do KingOfTokyo.GameSupervisor.which_children() |>

    Enum.each(fn {_, game_server_pid, _, _} -> {:ok, game} = GameServer.get_game(game_server_pid) game_id = game.code.game_id presence_player_ids = GameServer.presence_player_ids(game_id) if presence_player_ids == [] do Logger.info("No more players present on: #{game_id}, shutting down game server...") KingOfTokyo.GameSupervisor.stop_game(game_id) end end) {:noreply, state} end 42/67 — Dorian Karter | Boulevard | ElixirConf 2020
  25. Sessions ๏ Assigns are lost when the user refreshes the

    page socket |> assign(:player_id, player_id) ๏ You can't put_session on a socket socket |> put_session(:player_id, player_id) 47/67 — Dorian Karter | Boulevard | ElixirConf 2020
  26. Solving Sessions - Router scope "/", KingOfTokyoWeb do pipe_through :browser

    live("/", LobbyLive) get("/join_game", GameController, :join) get("/game", GameController, :index) end 48/67 — Dorian Karter | Boulevard | ElixirConf 2020
  27. Solving Sessions - LobbyLive def handle_info({:join_game, attrs}, socket) do #

    ... # NOOP - if started already KingOfTokyo.GameSupervisor.start_game(code) case GameServer.add_player(code.game_id, player) do :ok -> url = Routes.game_path( socket, :join, game_id: code.game_id, game_code: code.game_code, player_id: player.id ) socket |> put_temporary_flash(:info, "Joined successfully") |> push_redirect(to: url) # ... end # ... end 49/67 — Dorian Karter | Boulevard | ElixirConf 2020
  28. Injecting the session - GameController defmodule KingOfTokyoWeb.GameController do use KingOfTokyoWeb,

    :controller def join(conn, params) do %{"game_code" => game_code, "game_id" => game_id, "player_id" => player_id} = params url = Routes.game_path(conn, :index, game_code: game_code) conn |> put_session(:game_id, game_id) |> put_session(:player_id, player_id) |> redirect(to: url) end def index(conn, %{"game_code" => game_code}) do conn |> put_session(:game_code, game_code) |> live_render(KingOfTokyoWeb.GameLive) end end 50/67 — Dorian Karter | Boulevard | ElixirConf 2020
  29. Reading Session - GameLive def mount(_params, session, socket) do socket

    = with %{"game_id" => game_id, "player_id" => player_id} <- session, {:ok, game} <- GameServer.get_game(game_id), {:ok, player} <- GameServer.get_player_by_id(game_id, player_id), {:ok, _} <- Presence.track(self(), game_id, player_id, %{}), :ok <- Phoenix.PubSub.subscribe(KingOfTokyo.PubSub, game_id) do assign(socket, game: game, player: player) else _ -> # ... # player not found / no session, redirect back to lobby end {:ok, socket} end 51/67 — Dorian Karter | Boulevard | ElixirConf 2020
  30. Some LiveView errors do not show up locally ๏ Test

    on a QA server before release 53/67 — Dorian Karter | Boulevard | ElixirConf 2020
  31. Works in dev doesn't work on prod ๏ e.g. Key

    Events (e.g. phx-keyup/keydown) ๏ Read the CHANGELOG.md on LiveView's repo ๏ Disable browser extensions (e.g. Vimium) ๏ Test on a QA server before release 54/67 — Dorian Karter | Boulevard | ElixirConf 2020
  32. Gray Box Testing ๏ Kind of similar to Detox in

    React Native ๏ It has some knowledge about the internals ๏ Fast! ๏ Less brittle ๏ Still WIP and not always easy 56/67 — Dorian Karter | Boulevard | ElixirConf 2020
  33. Example test "cannot enter a game with no player name",

    %{conn: conn} do path = Routes.live_path(conn, KingOfTokyoWeb.LobbyLive, []) {:ok, view, _html} = live(conn, path) view |> form("#lobby form", %{ "character" => "the_king", "game_code" => "VALID_CODE", "player_name" => "" }) |> render_submit() assert render(view) =~ "name must be at least 2 characters long" end 57/67 — Dorian Karter | Boulevard | ElixirConf 2020
  34. Deployment $5 DigitalOcean Edition 58/67 — Dorian Karter | Boulevard

    | ElixirConf 2020 | Photo by Guillaume Bolduc
  35. Deployment ๏ Infrastructure: Pulumi (IaC) ๏ Configuration Management: Ansible ๏

    Release: Distillery ๏ Delivery: eDeliver (I have blog posts available) 59/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by RoseBox
  36. Nginx - Websocket Connection Upgrade location /live { proxy_pass http://127.0.0.1:4000$request_uri;

    proxy_http_version 1.1; proxy_set_header Upgrade default upgrade; proxy_set_header Connection "Upgrade"; proxy_set_header Host $host; } 61/67 — Dorian Karter | Boulevard | ElixirConf 2020
  37. eDeliver ๏ There are some bugs in eDeliver that have

    fixes merged but not released (start_erl.data) ๏ Make sure you restart the elixir server! 62/67 — Dorian Karter | Boulevard | ElixirConf 2020
  38. Deployment - Blog Post Series Covers Pulumi, Ansible, Distillery &

    eDeliver https://hashrocket.com/blog/rocketeers/dorian-karter 63/67 — Dorian Karter | Boulevard | ElixirConf 2020
  39. What's next ๏ Power Cards! ๏ WebRTC videochat? 64/67 —

    Dorian Karter | Boulevard | ElixirConf 2020
  40. Conclusion ๏ LiveView is AWESOME! ๏ Removes overhead of JavaScript

    ๏ Unparalleled development experience ๏ Enabling technology (everyone on the team is now full-stack) 65/67 — Dorian Karter | Boulevard | ElixirConf 2020
  41. Thank You! ๏ Jim Freeze & ElixirConf Team ๏ Chris

    McCord & Jose Valim + Elixir/Phoenix Core Team ๏ Paulo Daniel Gonzalez ๏ Boulevard (my employer - https://joinblvd.co) ๏ Friends @ Hashrocket (Chris Erin/Suzanne Erin/ Kory Roys) 66/67 — Dorian Karter | Boulevard | ElixirConf 2020
  42. Questions / Comments ๏ I'll be around in the hangout

    zoom channels ๏ Find me on Twitter (@dorian_escplan) ๏ Github (github.com/dkarter) ๏ Slides link will be posted to doriankarter.com ๏ Visit the game online at theking.live 67/67 — Dorian Karter | Boulevard | ElixirConf 2020