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
- What happens when a LiveView SPA grows? How to keep:
- 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/reconnectshandle error responses
Server
-
listen for events
-
manage sessions
- handle events
send responses-
interpret/render responses
handle disconnects/reconnectshandle malformed events
LiveView
- (simplified) Functional Reactive Programming
- 'The Elm Architecture'
- Essentially what all GenServers do
- 'The Elm Architecture'
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)
- nested tree of structs ('product & sum types')
- nested event handling
- 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
- Be sure to read the full LiveView documentation!
- upcoming LiveView book by Bruce Tate
- https://pragmaticstudio.com/phoenix-liveview (paid video guides)
Thank you!
Time for questions
Multiplayer Games & Collaborative Editing with Phoenix LiveView
By qqwy
Multiplayer Games & Collaborative Editing with Phoenix LiveView
- 1,343