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,
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
config :logger, :default_formatter,
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
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"]
]
# 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
config :logger, level: :info

View File

@@ -10,9 +10,6 @@ config :backend, BackendWeb.Endpoint,
# In test we don't send emails
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
config :logger, level: :warning

View File

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

View File

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

View File

@@ -24,6 +24,11 @@ defmodule Backend.GlobalSingleton do
end
defp monitor_loop(module) do
case :global.whereis_name(module) do
:undefined ->
# Double-check before attempting to start
Process.sleep(50)
case :global.whereis_name(module) do
:undefined ->
Logger.info("#{module} not running, attempting to start on #{node()}")
@@ -42,8 +47,14 @@ defmodule Backend.GlobalSingleton do
Process.sleep(100)
monitor_loop(module)
_pid ->
# Another node won the race
monitor_loop(module)
end
pid when is_pid(pid) ->
ref = Process.monitor(pid)
receive do
{:DOWN, ^ref, :process, ^pid, _reason} ->
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
)
current_state = Backend.GameState.get_state()
current_state = Backend.GameRunner.get_state()
{:ok, %{game_state: current_state}, socket}
end
@@ -55,7 +55,7 @@ defmodule BackendWeb.ConnectedUserChannel do
|> assign(:player_name, name)
|> assign(:keys_pressed, MapSet.new())
Backend.GameState.add_player(name)
Backend.GameRunner.add_player(name)
{:reply, :ok, socket}
end
@@ -75,7 +75,7 @@ defmodule BackendWeb.ConnectedUserChannel do
"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}
end
@@ -96,7 +96,7 @@ defmodule BackendWeb.ConnectedUserChannel do
"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}
end
@@ -110,7 +110,7 @@ defmodule BackendWeb.ConnectedUserChannel do
player_name ->
Logger.info("Player '#{player_name}' disconnected from #{node()}")
Backend.GameState.remove_player(player_name)
Backend.GameRunner.remove_player(player_name)
end
:ok

View File

@@ -5,6 +5,7 @@ defmodule BackendWeb.UserSocket do
## Channels
channel("user:*", BackendWeb.ConnectedUserChannel)
channel("clusterstatus", BackendWeb.ClusterStatusChannel)
@impl true
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])
live_dashboard("/dashboard", metrics: BackendWeb.Telemetry)
forward("/mailbox", Plug.Swoosh.MailboxPreview)
end
end
end

View File

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

View File

@@ -4,6 +4,7 @@ import { BoardDisplay } from "./game/BoardDisplay";
import { ConnectionStatus } from "./game/ConnectionStatus";
import { SessionOverride } from "./game/SessionOverride";
import { useGameChannelContext } from "./contexts/useGameChannelContext";
import { ClusterStatus } from "./clusterStatus/ClusterStatus";
const App: FC<{ playerName: string }> = ({ playerName }) => {
const { isOverridden } = useGameChannelContext();
@@ -15,8 +16,11 @@ const App: FC<{ playerName: string }> = ({ playerName }) => {
return (
<>
<UserInput playerName={playerName} />
<div className="w-screen h-screen bg-navy-900">
<div className="w-screen h-screen bg-navy-900 text-navy-100">
<div className="flex justify-between px-3">
<ConnectionStatus />
<ClusterStatus />
</div>
<BoardDisplay playerName={playerName} />
</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(),
});
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 }) => {
const { channel, isJoined, joinGame } = useGameChannelContext();
const [players, setPlayers] = useState<GameState>({});
const [players, setPlayers] = useState<Record<string, Player>>({});
useEffect(() => {
if (channel && !isJoined) {
@@ -25,18 +28,15 @@ export const BoardDisplay = ({ playerName }: { playerName: string }) => {
useEffect(() => {
if (!channel) return;
const ref = channel.on(
"game_state",
(payload: { game_state: unknown }) => {
const ref = channel.on("game_state", (payload: { game_state: unknown }) => {
const result = GameStateSchema.safeParse(payload.game_state);
if (result.success) {
setPlayers(result.data);
setPlayers(result.data.players);
} else {
console.error("Invalid game state received:", result.error);
setPlayers({});
}
},
);
});
return () => {
channel.off("game_state", ref);

View File

@@ -4,7 +4,6 @@ export const SessionOverride: FC = () => {
return (
<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="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-accent-500 mb-4">
Session Overridden
</h2>