Skip to content

Instantly share code, notes, and snippets.

@mjrusso
Created December 22, 2022 14:45
Show Gist options
  • Save mjrusso/c8b2d319a1ca37bba6038049d0793c7d to your computer and use it in GitHub Desktop.
Save mjrusso/c8b2d319a1ca37bba6038049d0793c7d to your computer and use it in GitHub Desktop.
A toy LiveView "game" example, using LiveView (as a single-file Phoenix app)
# A toy LiveView "game" example.
#
# Player locations are synchronized across all LiveView processes (browser
# tabs) using PubSub.
#
# Each player is represented by a symbol (e.g. '#'), and '.' characters
# represent open space on the grid.
#
# Use the arrow keys to move. There are no victory conditions -- you can simply
# move around.
#
# There are a maximum of four players; once all player slots are used up, any
# number of spectators can join.
#
# To run: `elixir liveviewgame.exs`
#
# To watch for changes, using entr:
#
# `echo liveviewgame.exs | entr -r elixir liveviewgame.exs`
Application.put_env(:game, Game.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 8081],
server: true,
live_view: [signing_salt: String.duplicate("a", 16)],
secret_key_base: String.duplicate("b", 64)
)
Mix.install([
{:plug_cowboy, "~> 2.6"},
{:jason, "~> 1.4"},
{:phoenix, "~> 1.7.0-rc.0", override: true},
{:phoenix_live_view, "~> 0.18.3"}
])
defmodule Game.Location do
defstruct x: 0, y: 0
end
defmodule Game.StateServer do
use GenServer
alias Phoenix.PubSub
alias Game.Location
@name :state_server
@type player :: String.t()
@type game_state_map :: %{
optional(player) => %Location{}
}
@start_value %{}
@players MapSet.new(["@", "#", "$", "%"])
@topic "game:state"
@board_width 16
@board_height 12
# Client
def topic, do: @topic
def board_width, do: @board_width
def board_height, do: @board_height
def start_link(_opts) do
GenServer.start_link(__MODULE__, @start_value, name: @name)
end
def join_game() do
GenServer.call(@name, :join)
end
def move_left(player) do
GenServer.call(@name, {:left, player})
end
def move_right(player) do
GenServer.call(@name, {:right, player})
end
def move_up(player) do
GenServer.call(@name, {:up, player})
end
def move_down(player) do
GenServer.call(@name, {:down, player})
end
@spec current() :: game_state_map
def current() do
GenServer.call(@name, :current)
end
# Server (callbacks)
def init(state) do
{:ok, state}
end
def handle_call(:join, _from, state) do
available_players =
@players
|> MapSet.difference(MapSet.new(Map.keys(state)))
|> MapSet.to_list()
case available_players do
[player | _] ->
new_state = Map.put(state, player, %Location{})
PubSub.broadcast(Game.PubSub, @topic, new_state)
{:reply, {:ok, player}, new_state}
_ ->
{:reply, {:error, nil}, state}
end
end
def handle_call(:current, _from, state) do
{:reply, state, state}
end
def handle_call({:left, player}, _from, state) do
move(state, player, -1, 0)
end
def handle_call({:right, player}, _from, state) do
move(state, player, +1, 0)
end
def handle_call({:up, player}, _from, state) do
move(state, player, 0, -1)
end
def handle_call({:down, player}, _from, state) do
move(state, player, 0, +1)
end
defp move(state, player, delta_x, delta_y) do
new_loc = %Location{
:x => (state[player].x + delta_x) |> max(0) |> min(@board_width - 1),
:y => (state[player].y + delta_y) |> max(0) |> min(@board_height - 1)
}
new_state = Map.put(state, player, new_loc)
PubSub.broadcast(Game.PubSub, @topic, new_state)
{:reply, new_state, new_state}
end
end
defmodule Game.Layouts do
use Phoenix.Component
def render("live.html", assigns) do
~H"""
<script src="https://cdn.jsdelivr.net/npm/[email protected]/priv/static/phoenix.min.js">
</script>
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/priv/static/phoenix_live_view.min.js"
>
</script>
<script>
const liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket);
liveSocket.connect();
</script>
<script src="https://cdn.tailwindcss.com">
</script>
<%= @inner_content %>
"""
end
end
defmodule Game.ErrorView do
def render(_, _), do: "error"
end
defmodule Game.GameLive do
use Phoenix.LiveView, layout: {Game.Layouts, :live}
alias Game.StateServer
alias Game.Location
@impl true
def render(assigns) do
~H"""
<div phx-window-keyup="move">
<div class="font-mono whitespace-pre mx-12 my-4"><%= draw_grid(@state) %></div>
<hr>
<div class="font-mono mt-8 mx-12">
<%= if @player do %>
<strong><%= @player %>:</strong>
<%= @state[@player].x %>
<%= @state[@player].y %>
<%= else %>
<i>spectating</i>
<% end %>
</div>
</div>
"""
end
@board_width StateServer.board_width()
@board_height StateServer.board_height()
defp draw_grid(state) do
Enum.join(
for y <- 0..(@board_height - 1) do
for x <- 0..(@board_width - 1) do
player_or_nil =
Enum.find_value(
state,
fn {k, v} -> if v.x == x and v.y == y, do: k end
)
needs_newline? = x == @board_width - 1
Enum.join(
[
player_or_nil || ".",
if(needs_newline?, do: "\n", else: "")
],
""
)
end
end
)
end
@topic StateServer.topic()
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Game.PubSub, @topic)
{status, player} = StateServer.join_game()
case status do
:ok ->
IO.puts("Playing as #{player}")
:error ->
IO.puts("Could not join game; spectating instead")
end
{:ok, assign(socket, %{:player => player, :state => StateServer.current()})}
else
{:ok, assign(socket, %{:player => nil, :state => StateServer.current()})}
end
end
@impl true
def handle_event("move", %{"key" => key}, socket) do
player = socket.assigns.player
state =
case {player, key} do
{nil, _} ->
StateServer.current()
{_, "ArrowUp"} ->
StateServer.move_up(player)
{_, "ArrowDown"} ->
StateServer.move_down(player)
{_, "ArrowLeft"} ->
StateServer.move_left(player)
{_, "ArrowRight"} ->
StateServer.move_right(player)
_ ->
StateServer.current()
end
{:noreply, assign(socket, :state, state)}
end
def handle_event("move", _, socket) do
{:noreply, socket}
end
@impl true
def handle_info(msg, socket) do
{:noreply, assign(socket, :state, msg)}
end
end
defmodule Game.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug(:accepts, ["html"])
end
scope "/", Game do
pipe_through(:browser)
live("/", GameLive, :index)
end
end
defmodule Game.Endpoint do
use Phoenix.Endpoint, otp_app: :game
socket("/live", Phoenix.LiveView.Socket)
plug(Game.Router)
end
# Application startup
{:ok, _} =
Supervisor.start_link(
[
Game.Endpoint,
{Phoenix.PubSub, name: Game.PubSub},
Game.StateServer
],
strategy: :one_for_one
)
Process.sleep(:infinity)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment