Created
December 22, 2022 14:45
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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