Slide 1

Slide 1 text

Multiplayer Games with Phoenix LiveView* 1/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

! Dorian Karter Sr. Software Engineer @ Boulevard Organizer @ ChicagoElixir Meetup 4/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 5

Slide 5 text

Motivation 5/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Cristofer Jeschke

Slide 6

Slide 6 text

King of Tokyo 6/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 7

Slide 7 text

March 2020 7/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Fusion Medical Animation

Slide 8

Slide 8 text

8/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

10/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 11

Slide 11 text

Solution: Build Something Cool with LiveView 11/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Júnior Ferreira

Slide 12

Slide 12 text

KOT Basics Breeze-through 12/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 13

Slide 13 text

13/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 14

Slide 14 text

14/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

16/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 17

Slide 17 text

Architecture 17/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Rémi Bertogliati

Slide 18

Slide 18 text

Simplified OTP Design 18/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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"""

The count is: <%= @val %>

- +
""" end def mount(_session, socket) do {:ok, assign(socket, :val, 0)} end end 22/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 23

Slide 23 text

LobbyLive 23/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

Player Groups DynamicSupervisor & GenServer 25/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Fabrizio Frigeni

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

Managing GameServers Registry 27/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Dima Kolesnyk

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

Who's Online? Phoenix Presence 30/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Dean Nahum

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

Pushing Updates Phoenix PubSub 34/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Mathyas Kurmann

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

GameOver GarbageCollector 39/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Randy Fath

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

Error Recovery 43/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Sarah Kilian

Slide 44

Slide 44 text

44/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 45

Slide 45 text

45/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 46

Slide 46 text

46/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

Pitfalls 52/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by James Fitzgerald

Slide 53

Slide 53 text

Some LiveView errors do not show up locally ๏ Test on a QA server before release 53/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

Testing 55/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Fleur

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

Deployment $5 DigitalOcean Edition 58/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Guillaume Bolduc

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

Pitfalls 60/67 — Dorian Karter | Boulevard | ElixirConf 2020 | Photo by Mana Nabavian

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

Deployment - Blog Post Series Covers Pulumi, Ansible, Distillery & eDeliver https://hashrocket.com/blog/rocketeers/dorian-karter 63/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 64

Slide 64 text

What's next ๏ Power Cards! ๏ WebRTC videochat? 64/67 — Dorian Karter | Boulevard | ElixirConf 2020

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

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