improving game

This commit is contained in:
2026-03-02 16:31:44 -07:00
parent 0dae393d0d
commit c009d23e76
17 changed files with 144 additions and 84 deletions

View File

@@ -21,15 +21,6 @@ config :backend, BackendWeb.Endpoint,
pubsub_server: Backend.PubSub, pubsub_server: Backend.PubSub,
live_view: [signing_salt: "E+VIFYOy"] live_view: [signing_salt: "E+VIFYOy"]
# Configure the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :backend, Backend.Mailer, adapter: Swoosh.Adapters.Local
# Configure Elixir's Logger # Configure Elixir's Logger
config :logger, :default_formatter, config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n", format: "$time $metadata[$level] $message\n",

View File

@@ -63,6 +63,3 @@ config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation # Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime config :phoenix, :plug_init_mode, :runtime
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false

View File

@@ -10,12 +10,6 @@ config :backend, BackendWeb.Endpoint,
hosts: ["localhost", "127.0.0.1"] hosts: ["localhost", "127.0.0.1"]
] ]
# Configure Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Req
# Disable Swoosh Local Memory Storage
config :swoosh, local: false
# Do not print debug messages in production # Do not print debug messages in production
config :logger, level: :info config :logger, level: :info

View File

@@ -10,9 +10,6 @@ config :backend, BackendWeb.Endpoint,
# In test we don't send emails # In test we don't send emails
config :backend, Backend.Mailer, adapter: Swoosh.Adapters.Test config :backend, Backend.Mailer, adapter: Swoosh.Adapters.Test
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false
# Print only warnings and errors during test # Print only warnings and errors during test
config :logger, level: :warning config :logger, level: :warning

View File

@@ -8,7 +8,7 @@ defmodule Backend.Application do
BackendWeb.Telemetry, BackendWeb.Telemetry,
{Phoenix.PubSub, name: Backend.PubSub}, {Phoenix.PubSub, name: Backend.PubSub},
Backend.Cluster, Backend.Cluster,
{Backend.GlobalSingleton, Backend.GameState}, {Backend.GlobalSingleton, Backend.GameRunner},
BackendWeb.Endpoint BackendWeb.Endpoint
] ]

View File

@@ -1,4 +1,4 @@
defmodule Backend.GameState do defmodule Backend.GameRunner do
@moduledoc """ @moduledoc """
GenServer to track all players and their positions in the game. GenServer to track all players and their positions in the game.
Uses :global registry for distributed singleton pattern - only one instance Uses :global registry for distributed singleton pattern - only one instance
@@ -27,7 +27,7 @@ defmodule Backend.GameState do
def add_player(player_id) do def add_player(player_id) do
Logger.info("Player #{player_id} connected") Logger.info("Player #{player_id} connected")
GenServer.call(@name, {:add_player, player_id}) GenServer.cast(@name, {:add_player, player_id})
end end
def remove_player(player_id) do def remove_player(player_id) do
@@ -56,24 +56,27 @@ defmodule Backend.GameState do
sleep_delay = round(1000 / @fps) sleep_delay = round(1000 / @fps)
:timer.send_interval(sleep_delay, :tick) :timer.send_interval(sleep_delay, :tick)
{:ok, %{}} {:ok, %{players: %{}, tick_number: 0}}
end end
@impl true @impl true
def handle_call({:add_player, player_id}, _from, state) do def handle_cast({:add_player, player_id}, _from, state) do
# Start players at random position # Start players at random position
x = :rand.uniform(800) x = :rand.uniform(800)
y = :rand.uniform(600) y = :rand.uniform(600)
new_state = Map.put(state, player_id, %{x: x, y: y, keys_pressed: []}) new_state = %{
broadcast_state(new_state) state
{:reply, new_state, new_state} | players: state.players |> Map.put(player_id, %{x: x, y: y, keys_pressed: []})
}
{:noreply, broadcast_state(new_state)}
end end
@impl true @impl true
def handle_cast({:update_player_keys, player_id, keys_pressed}, state) do def handle_cast({:update_player_keys, player_id, keys_pressed}, state) do
player = player =
case Map.get(state, player_id) do case Map.get(state.players, player_id) do
nil -> nil ->
Logger.warning( Logger.warning(
"Key update for non-existent player: #{player_id}, creating at random position" "Key update for non-existent player: #{player_id}, creating at random position"
@@ -85,18 +88,23 @@ defmodule Backend.GameState do
Map.put(existing_player, :keys_pressed, keys_pressed) Map.put(existing_player, :keys_pressed, keys_pressed)
end end
new_state = Map.put(state, player_id, player) new_state = %{state | players: Map.put(state.players, player_id, player)}
broadcast_state(new_state)
{:noreply, new_state} {:noreply, broadcast_state(new_state)}
end end
@impl true @impl true
def handle_info(:tick, state) do def handle_info(:tick, state) do
# On each tick, move players based on their keys_pressed if rem(state.tick_number, 100) == 0 do
new_state = Logger.info("Game tick #{state.tick_number} on #{node()}, updating player positions")
Enum.reduce(state, state, fn {player_id, player}, acc -> end
step = 10
new_players =
state.players
|> Enum.map(fn {player_id, player} ->
directions = player.keys_pressed || [] directions = player.keys_pressed || []
step = 10
new_player = new_player =
Enum.reduce(directions, player, fn direction, acc -> Enum.reduce(directions, player, fn direction, acc ->
@@ -109,11 +117,13 @@ defmodule Backend.GameState do
end end
end) end)
Map.put(acc, player_id, new_player) {player_id, new_player}
end) end)
|> Enum.into(%{})
broadcast_state(new_state) new_state = %{state | players: new_players, tick_number: state.tick_number + 1}
{:noreply, new_state}
{:noreply, broadcast_state(new_state)}
end end
@impl true @impl true
@@ -123,14 +133,14 @@ defmodule Backend.GameState do
@impl true @impl true
def handle_cast({:remove_player, player_id}, state) do def handle_cast({:remove_player, player_id}, state) do
new_state = Map.delete(state, player_id) players = Map.delete(state.players, player_id)
broadcast_state(new_state) new_state = %{state | players: players}
{:noreply, new_state}
end
# Private Functions {:noreply, broadcast_state(new_state)}
end
defp broadcast_state(state) do defp broadcast_state(state) do
Phoenix.PubSub.broadcast(@pubsub, @topic, {:game_state_updated, state}) Phoenix.PubSub.broadcast(@pubsub, @topic, {:game_state_updated, state})
state
end end
end end

View File

@@ -26,24 +26,35 @@ defmodule Backend.GlobalSingleton do
defp monitor_loop(module) do defp monitor_loop(module) do
case :global.whereis_name(module) do case :global.whereis_name(module) do
:undefined -> :undefined ->
Logger.info("#{module} not running, attempting to start on #{node()}") # Double-check before attempting to start
Process.sleep(50)
case module.start_link([]) do case :global.whereis_name(module) do
{:ok, _pid} -> :undefined ->
Logger.info("#{module} started on #{node()}") Logger.info("#{module} not running, attempting to start on #{node()}")
{:error, {:already_started, _pid}} -> case module.start_link([]) do
Logger.debug("#{module} already started by another node") {:ok, _pid} ->
Logger.info("#{module} started on #{node()}")
_ -> {:error, {:already_started, _pid}} ->
:ok Logger.debug("#{module} already started by another node")
_ ->
:ok
end
Process.sleep(100)
monitor_loop(module)
_pid ->
# Another node won the race
monitor_loop(module)
end end
Process.sleep(100)
monitor_loop(module)
pid when is_pid(pid) -> pid when is_pid(pid) ->
ref = Process.monitor(pid) ref = Process.monitor(pid)
receive do receive do
{:DOWN, ^ref, :process, ^pid, _reason} -> {:DOWN, ^ref, :process, ^pid, _reason} ->
Logger.warning("#{module} went down, attempting takeover") Logger.warning("#{module} went down, attempting takeover")

View File

@@ -1,3 +0,0 @@
defmodule Backend.Mailer do
use Swoosh.Mailer, otp_app: :backend
end

View File

@@ -0,0 +1,18 @@
defmodule BackendWeb.ClusterStatusChannel do
@moduledoc """
Channel for cluster status information
"""
use BackendWeb, :channel
require Logger
@impl true
def join("clusterstatus", _params, socket) do
Logger.info("Client joined clusterstatus channel")
{:ok, %{status: "connected"}, socket}
end
@impl true
def handle_in(_event, _payload, socket) do
{:noreply, socket}
end
end

View File

@@ -20,7 +20,7 @@ defmodule BackendWeb.ConnectedUserChannel do
:new_browser_connection :new_browser_connection
) )
current_state = Backend.GameState.get_state() current_state = Backend.GameRunner.get_state()
{:ok, %{game_state: current_state}, socket} {:ok, %{game_state: current_state}, socket}
end end
@@ -55,7 +55,7 @@ defmodule BackendWeb.ConnectedUserChannel do
|> assign(:player_name, name) |> assign(:player_name, name)
|> assign(:keys_pressed, MapSet.new()) |> assign(:keys_pressed, MapSet.new())
Backend.GameState.add_player(name) Backend.GameRunner.add_player(name)
{:reply, :ok, socket} {:reply, :ok, socket}
end end
@@ -75,7 +75,7 @@ defmodule BackendWeb.ConnectedUserChannel do
"Player '#{player_name}' key down: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}" "Player '#{player_name}' key down: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}"
) )
Backend.GameState.update_player_keys(player_name, MapSet.to_list(keys_pressed)) Backend.GameRunner.update_player_keys(player_name, MapSet.to_list(keys_pressed))
{:noreply, socket} {:noreply, socket}
end end
@@ -96,7 +96,7 @@ defmodule BackendWeb.ConnectedUserChannel do
"Player '#{player_name}' key up: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}" "Player '#{player_name}' key up: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}"
) )
Backend.GameState.update_player_keys(player_name, MapSet.to_list(keys_pressed)) Backend.GameRunner.update_player_keys(player_name, MapSet.to_list(keys_pressed))
{:noreply, socket} {:noreply, socket}
end end
@@ -110,7 +110,7 @@ defmodule BackendWeb.ConnectedUserChannel do
player_name -> player_name ->
Logger.info("Player '#{player_name}' disconnected from #{node()}") Logger.info("Player '#{player_name}' disconnected from #{node()}")
Backend.GameState.remove_player(player_name) Backend.GameRunner.remove_player(player_name)
end end
:ok :ok

View File

@@ -5,6 +5,7 @@ defmodule BackendWeb.UserSocket do
## Channels ## Channels
channel("user:*", BackendWeb.ConnectedUserChannel) channel("user:*", BackendWeb.ConnectedUserChannel)
channel("clusterstatus", BackendWeb.ClusterStatusChannel)
@impl true @impl true
def connect(%{"user_name" => user_name}, socket, _connect_info) do def connect(%{"user_name" => user_name}, socket, _connect_info) do

View File

@@ -24,7 +24,6 @@ defmodule BackendWeb.Router do
pipe_through([:fetch_session, :protect_from_forgery]) pipe_through([:fetch_session, :protect_from_forgery])
live_dashboard("/dashboard", metrics: BackendWeb.Telemetry) live_dashboard("/dashboard", metrics: BackendWeb.Telemetry)
forward("/mailbox", Plug.Swoosh.MailboxPreview)
end end
end end
end end

View File

@@ -41,7 +41,6 @@ defmodule Backend.MixProject do
[ [
{:phoenix, "~> 1.8.4"}, {:phoenix, "~> 1.8.4"},
{:phoenix_live_dashboard, "~> 0.8.3"}, {:phoenix_live_dashboard, "~> 0.8.3"},
{:swoosh, "~> 1.16"},
{:req, "~> 0.5"}, {:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"}, {:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},

View File

@@ -4,6 +4,7 @@ import { BoardDisplay } from "./game/BoardDisplay";
import { ConnectionStatus } from "./game/ConnectionStatus"; import { ConnectionStatus } from "./game/ConnectionStatus";
import { SessionOverride } from "./game/SessionOverride"; import { SessionOverride } from "./game/SessionOverride";
import { useGameChannelContext } from "./contexts/useGameChannelContext"; import { useGameChannelContext } from "./contexts/useGameChannelContext";
import { ClusterStatus } from "./clusterStatus/ClusterStatus";
const App: FC<{ playerName: string }> = ({ playerName }) => { const App: FC<{ playerName: string }> = ({ playerName }) => {
const { isOverridden } = useGameChannelContext(); const { isOverridden } = useGameChannelContext();
@@ -15,8 +16,11 @@ const App: FC<{ playerName: string }> = ({ playerName }) => {
return ( return (
<> <>
<UserInput playerName={playerName} /> <UserInput playerName={playerName} />
<div className="w-screen h-screen bg-navy-900"> <div className="w-screen h-screen bg-navy-900 text-navy-100">
<ConnectionStatus /> <div className="flex justify-between px-3">
<ConnectionStatus />
<ClusterStatus />
</div>
<BoardDisplay playerName={playerName} /> <BoardDisplay playerName={playerName} />
</div> </div>
</> </>

View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import { useWebSocketContext } from "../contexts/useWebSocketContext";
export const ClusterStatus = () => {
const { socket, isConnected } = useWebSocketContext();
const [channelStatus, setChannelStatus] = useState<string>("waiting");
useEffect(() => {
if (!socket || !isConnected) {
return;
}
const channelName = "clusterstatus";
console.log(`Joining channel: ${channelName}`);
const newChannel = socket.channel(channelName, {});
newChannel
.join()
.receive("ok", () => {
setChannelStatus("connected");
})
.receive("error", (resp: unknown) => {
console.log(`Failed to join channel ${channelName}:`, resp);
setChannelStatus("join failed");
})
.receive("timeout", () => {
setChannelStatus("timeout");
});
return () => {
console.log(`Leaving channel: ${channelName}`);
newChannel.leave();
setChannelStatus("waiting");
};
}, [socket, isConnected]);
return (
<div>
<div>ClusterStatus</div>
<div>Channel: {channelStatus}</div>
</div>
);
}

View File

@@ -8,13 +8,16 @@ const PlayerSchema = z.object({
y: z.number(), y: z.number(),
}); });
const GameStateSchema = z.record(z.string(), PlayerSchema); const GameStateSchema = z.object({
players: z.record(z.string(), PlayerSchema),
tick_number: z.number(),
});
type GameState = z.infer<typeof GameStateSchema>; type Player = z.infer<typeof PlayerSchema>;
export const BoardDisplay = ({ playerName }: { playerName: string }) => { export const BoardDisplay = ({ playerName }: { playerName: string }) => {
const { channel, isJoined, joinGame } = useGameChannelContext(); const { channel, isJoined, joinGame } = useGameChannelContext();
const [players, setPlayers] = useState<GameState>({}); const [players, setPlayers] = useState<Record<string, Player>>({});
useEffect(() => { useEffect(() => {
if (channel && !isJoined) { if (channel && !isJoined) {
@@ -25,18 +28,15 @@ export const BoardDisplay = ({ playerName }: { playerName: string }) => {
useEffect(() => { useEffect(() => {
if (!channel) return; if (!channel) return;
const ref = channel.on( const ref = channel.on("game_state", (payload: { game_state: unknown }) => {
"game_state", const result = GameStateSchema.safeParse(payload.game_state);
(payload: { game_state: unknown }) => { if (result.success) {
const result = GameStateSchema.safeParse(payload.game_state); setPlayers(result.data.players);
if (result.success) { } else {
setPlayers(result.data); console.error("Invalid game state received:", result.error);
} else { setPlayers({});
console.error("Invalid game state received:", result.error); }
setPlayers({}); });
}
},
);
return () => { return () => {
channel.off("game_state", ref); channel.off("game_state", ref);

View File

@@ -4,7 +4,6 @@ export const SessionOverride: FC = () => {
return ( return (
<div className="fixed inset-0 bg-navy-900/95 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-navy-900/95 flex items-center justify-center z-50">
<div className="bg-navy-800 border-2 border-accent-600 rounded-lg p-8 max-w-md text-center"> <div className="bg-navy-800 border-2 border-accent-600 rounded-lg p-8 max-w-md text-center">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-accent-500 mb-4"> <h2 className="text-2xl font-bold text-accent-500 mb-4">
Session Overridden Session Overridden
</h2> </h2>