Building Multiplayer Games with Phoenix LiveView

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.

0e752ec9121eb5ebc9924f5b2e4b788e?s=128

Dorian Karter

September 03, 2020
Tweet

Transcript

  1. Multiplayer Games with Phoenix LiveView* 1/67 — Dorian Karter |

    Boulevard | ElixirConf 2020
  2. 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
  3. 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
  4. ! Dorian Karter Sr. Software Engineer @ Boulevard Organizer @

    ChicagoElixir Meetup 4/67 — Dorian Karter | Boulevard | ElixirConf 2020
  5. Motivation 5/67 — Dorian Karter | Boulevard | ElixirConf 2020

    | Photo by Cristofer Jeschke
  6. King of Tokyo 6/67 — Dorian Karter | Boulevard |

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

    2020 | Photo by Fusion Medical Animation
  8. 8/67 — Dorian Karter | Boulevard | ElixirConf 2020

  9. Remote KOT 9/67 — Dorian Karter | Boulevard | ElixirConf

    2020
  10. 10/67 — Dorian Karter | Boulevard | ElixirConf 2020

  11. Solution: Build Something Cool with LiveView 11/67 — Dorian Karter

    | Boulevard | ElixirConf 2020 | Photo by Júnior Ferreira
  12. KOT Basics Breeze-through 12/67 — Dorian Karter | Boulevard |

    ElixirConf 2020
  13. 13/67 — Dorian Karter | Boulevard | ElixirConf 2020

  14. 14/67 — Dorian Karter | Boulevard | ElixirConf 2020

  15. 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
  16. 16/67 — Dorian Karter | Boulevard | ElixirConf 2020

  17. Architecture 17/67 — Dorian Karter | Boulevard | ElixirConf 2020

    | Photo by Rémi Bertogliati
  18. Simplified OTP Design 18/67 — Dorian Karter | Boulevard |

    ElixirConf 2020
  19. Realistic OTP Architecture 19/67 — Dorian Karter | Boulevard |

    ElixirConf 2020
  20. 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
  21. 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
  22. 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
  23. LobbyLive 23/67 — Dorian Karter | Boulevard | ElixirConf 2020

  24. GameLive PlayerList, PlayerCard, Dice 24/67 — Dorian Karter | Boulevard

    | ElixirConf 2020
  25. Player Groups DynamicSupervisor & GenServer 25/67 — Dorian Karter |

    Boulevard | ElixirConf 2020 | Photo by Fabrizio Frigeni
  26. 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
  27. Managing GameServers Registry 27/67 — Dorian Karter | Boulevard |

    ElixirConf 2020 | Photo by Dima Kolesnyk
  28. 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
  29. 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
  30. Who's Online? Phoenix Presence 30/67 — Dorian Karter | Boulevard

    | ElixirConf 2020 | Photo by Dean Nahum
  31. 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
  32. 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
  33. 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
  34. Pushing Updates Phoenix PubSub 34/67 — Dorian Karter | Boulevard

    | ElixirConf 2020 | Photo by Mathyas Kurmann
  35. 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
  36. 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
  37. 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
  38. 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
  39. GameOver GarbageCollector 39/67 — Dorian Karter | Boulevard | ElixirConf

    2020 | Photo by Randy Fath
  40. 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
  41. 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
  42. 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
  43. Error Recovery 43/67 — Dorian Karter | Boulevard | ElixirConf

    2020 | Photo by Sarah Kilian
  44. 44/67 — Dorian Karter | Boulevard | ElixirConf 2020

  45. 45/67 — Dorian Karter | Boulevard | ElixirConf 2020

  46. 46/67 — Dorian Karter | Boulevard | ElixirConf 2020

  47. 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
  48. 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
  49. 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
  50. 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
  51. 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
  52. Pitfalls 52/67 — Dorian Karter | Boulevard | ElixirConf 2020

    | Photo by James Fitzgerald
  53. Some LiveView errors do not show up locally ๏ Test

    on a QA server before release 53/67 — Dorian Karter | Boulevard | ElixirConf 2020
  54. 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
  55. Testing 55/67 — Dorian Karter | Boulevard | ElixirConf 2020

    | Photo by Fleur
  56. 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
  57. 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
  58. Deployment $5 DigitalOcean Edition 58/67 — Dorian Karter | Boulevard

    | ElixirConf 2020 | Photo by Guillaume Bolduc
  59. 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
  60. Pitfalls 60/67 — Dorian Karter | Boulevard | ElixirConf 2020

    | Photo by Mana Nabavian
  61. 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
  62. 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
  63. Deployment - Blog Post Series Covers Pulumi, Ansible, Distillery &

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

    Dorian Karter | Boulevard | ElixirConf 2020
  65. 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
  66. 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
  67. 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