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

Building Multiplayer Games with Phoenix LiveView

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers.
Avatar for Dorian Karter 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.

Avatar for Dorian Karter

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