From 8bb086eac9cbb49048b2c6e4c33e9cf968ed8043 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Tue, 24 Feb 2026 12:56:54 -0700 Subject: [PATCH] refactored frontent to separate files --- .envrc | 1 - client/src/App.tsx | 208 ++----------------- client/src/contexts/GameChannelContext.tsx | 81 ++++++++ client/src/contexts/WebSocketContext.tsx | 78 +++++++ client/src/contexts/useGameChannelContext.ts | 10 + client/src/contexts/useWebSocketContext.ts | 10 + client/src/game/BoardDisplay.tsx | 89 ++++++++ client/src/game/ConnectionStatus.tsx | 26 +++ client/src/game/UserInput.tsx | 45 ++++ client/src/main.tsx | 22 +- flake.lock | 61 ------ flake.nix | 39 ---- nginx-lb.conf | 8 +- 13 files changed, 369 insertions(+), 309 deletions(-) delete mode 100644 .envrc create mode 100644 client/src/contexts/GameChannelContext.tsx create mode 100644 client/src/contexts/WebSocketContext.tsx create mode 100644 client/src/contexts/useGameChannelContext.ts create mode 100644 client/src/contexts/useWebSocketContext.ts create mode 100644 client/src/game/BoardDisplay.tsx create mode 100644 client/src/game/ConnectionStatus.tsx create mode 100644 client/src/game/UserInput.tsx delete mode 100644 flake.lock delete mode 100644 flake.nix diff --git a/.envrc b/.envrc deleted file mode 100644 index 3550a30..0000000 --- a/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake diff --git a/client/src/App.tsx b/client/src/App.tsx index d29500a..c355dd0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,209 +1,25 @@ -import { useEffect, useRef, useState } from "react"; -import { Socket, Channel } from "phoenix"; import "./App.css"; - -interface Player { - x: number; - y: number; -} - -interface GameState { - [playerId: string]: Player; -} - -// Connect to nginx load balancer -const WS_SERVER = "ws://localhost:4000/socket"; +import { UserInput } from "./game/UserInput"; +import { BoardDisplay } from "./game/BoardDisplay"; +import { ConnectionStatus } from "./game/ConnectionStatus"; function App() { - const [players, setPlayers] = useState({}); - const [myPlayerId, setMyPlayerId] = useState(null); - const [connectionStatus, setConnectionStatus] = - useState("connecting"); - const socketRef = useRef(null); - const channelRef = useRef(null); - const keysPressed = useRef>(new Set()); - - useEffect(() => { - // Connect to nginx load balancer - console.log(`Connecting to ${WS_SERVER}`); - - const socket = new Socket(WS_SERVER, { - timeout: 3000, - reconnectAfterMs: (tries) => - [1000, 2000, 5000, 10000][tries - 1] || 10000, - }); - - socket.onOpen(() => { - console.log(`✓ Connected to load balancer`); - setConnectionStatus("Connected"); - }); - - socket.onError((error) => { - console.error(`✗ Connection error:`, error); - setConnectionStatus("Connection error"); - }); - - socket.onClose(() => { - console.log(`✗ Disconnected from load balancer`); - setConnectionStatus("Disconnected - reconnecting..."); - }); - - socket.connect(); - socketRef.current = socket; - - // Join game channel - const channel = socket.channel("game:lobby", {}); - - channel - .join() - .receive("ok", () => { - console.log(`✓ Joined game channel`); - setConnectionStatus("Connected & playing"); - }) - .receive("error", (resp) => { - console.log(`✗ Failed to join:`, resp); - setConnectionStatus("Failed to join game"); - }) - .receive("timeout", () => { - console.log(`✗ Timeout joining`); - setConnectionStatus("Connection timeout"); - }); - - // Listen for game state updates - channel.on("game_state", (payload: { players: GameState }) => { - setPlayers(payload.players); - - if (!myPlayerId && Object.keys(payload.players).length > 0) { - const playerIds = Object.keys(payload.players); - if (playerIds.length > 0) { - setMyPlayerId(playerIds[playerIds.length - 1]); - } - } - }); - - channelRef.current = channel; - - // Cleanup on unmount - return () => { - channel.leave(); - socket.disconnect(); - }; - }, [myPlayerId]); - - // Handle keyboard input - send to active channel - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - const key = e.key.toLowerCase(); - if (["w", "a", "s", "d"].includes(key)) { - e.preventDefault(); - - // Only send if not already pressed (prevent repeat) - if (!keysPressed.current.has(key)) { - keysPressed.current.add(key); - - // Send to active channel - const activeChannel = channelRef.current; - if (activeChannel && activeChannel.state === "joined") { - activeChannel.push("move", { direction: key }); - } - } - } - }; - - const handleKeyUp = (e: KeyboardEvent) => { - const key = e.key.toLowerCase(); - if (["w", "a", "s", "d"].includes(key)) { - keysPressed.current.delete(key); - } - }; - - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - }; - }, []); - return ( -
- {/* Connection status */} + <> +
-
Status: {connectionStatus}
-
Players: {Object.keys(players).length}
-
- - {/* Game canvas */} -
- {Object.entries(players).map(([id, player]) => ( -
- {id === myPlayerId && ( -
- You -
- )} -
- ))} + +
-
+ ); } diff --git a/client/src/contexts/GameChannelContext.tsx b/client/src/contexts/GameChannelContext.tsx new file mode 100644 index 0000000..1388c37 --- /dev/null +++ b/client/src/contexts/GameChannelContext.tsx @@ -0,0 +1,81 @@ +import { createContext, useEffect, useState, type ReactNode } from "react"; +import { Channel } from "phoenix"; +import { useWebSocketContext } from "./useWebSocketContext"; + +interface GameChannelContextValue { + channel: Channel | null; + channelStatus: string; + isJoined: boolean; +} + +const GameChannelContext = createContext( + undefined, +); + +interface GameChannelProviderProps { + channelName: string; + params?: Record; + children: ReactNode; +} + +export function GameChannelProvider({ + channelName, + params = {}, + children, +}: GameChannelProviderProps) { + const { socket, isConnected } = useWebSocketContext(); + const [channelStatus, setChannelStatus] = useState("waiting"); + const [isJoined, setIsJoined] = useState(false); + const [channel, setChannel] = useState(null); + + useEffect(() => { + if (!socket || !isConnected) { + return; + } + + console.log(`Joining channel: ${channelName}`); + const newChannel = socket.channel(channelName, params); + + newChannel + .join() + .receive("ok", () => { + console.log(`✓ Joined channel: ${channelName}`); + setChannelStatus("joined"); + setIsJoined(true); + }) + .receive("error", (resp: unknown) => { + console.log(`✗ Failed to join ${channelName}:`, resp); + setChannelStatus("join failed"); + setIsJoined(false); + }) + .receive("timeout", () => { + console.log(`✗ Timeout joining ${channelName}`); + setChannelStatus("timeout"); + setIsJoined(false); + }); + + // Set channel asynchronously to avoid cascading renders + setTimeout(() => setChannel(newChannel), 0); + + return () => { + console.log(`Leaving channel: ${channelName}`); + newChannel.leave(); + setChannel(null); + setIsJoined(false); + }; + }, [socket, isConnected, channelName, params]); + + return ( + + {children} + + ); +} + +export { GameChannelContext }; diff --git a/client/src/contexts/WebSocketContext.tsx b/client/src/contexts/WebSocketContext.tsx new file mode 100644 index 0000000..3c6529f --- /dev/null +++ b/client/src/contexts/WebSocketContext.tsx @@ -0,0 +1,78 @@ +import { createContext, useEffect, useState, type ReactNode } from "react"; +import { Socket } from "phoenix"; + +interface WebSocketContextValue { + socket: Socket | null; + connectionStatus: string; + isConnected: boolean; +} + +const WebSocketContext = createContext( + undefined, +); + +interface WebSocketProviderProps { + url: string; + children: ReactNode; +} + +export function WebSocketProvider({ url, children }: WebSocketProviderProps) { + const [connectionStatus, setConnectionStatus] = + useState("connecting"); + const [isConnected, setIsConnected] = useState(false); + const [socket, setSocket] = useState(null); + + useEffect(() => { + console.log(`Connecting to ${url}`); + + const newSocket = new Socket(url, { + timeout: 100, + reconnectAfterMs: (tries) => + [ + 300, 300, 300, 300, 300, 300, 300, 300, 300, 300, 300, 300, 300, 300, + 300, 5000, 10000, + ][tries - 1] || 10000, + }); + + newSocket.onOpen(() => { + console.log(`✓ Connected to server`); + setConnectionStatus("Connected"); + setIsConnected(true); + }); + + newSocket.onError((error) => { + console.error(`✗ Connection error:`, error); + setConnectionStatus("Connection error"); + setIsConnected(false); + }); + + newSocket.onClose(() => { + console.log(`✗ Disconnected from server`); + setConnectionStatus("Disconnected - reconnecting..."); + setIsConnected(false); + }); + + newSocket.connect(); + // Set socket state asynchronously to avoid cascading renders + setTimeout(() => setSocket(newSocket), 0); + + return () => { + newSocket.disconnect(); + setSocket(null); + }; + }, [url]); + + return ( + + {children} + + ); +} + +export { WebSocketContext }; diff --git a/client/src/contexts/useGameChannelContext.ts b/client/src/contexts/useGameChannelContext.ts new file mode 100644 index 0000000..d31508a --- /dev/null +++ b/client/src/contexts/useGameChannelContext.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { GameChannelContext } from "./GameChannelContext"; + +export function useGameChannelContext() { + const context = useContext(GameChannelContext); + if (context === undefined) { + throw new Error("useGameChannel must be used within a GameChannelProvider"); + } + return context; +} diff --git a/client/src/contexts/useWebSocketContext.ts b/client/src/contexts/useWebSocketContext.ts new file mode 100644 index 0000000..4630516 --- /dev/null +++ b/client/src/contexts/useWebSocketContext.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { WebSocketContext } from "./WebSocketContext"; + +export function useWebSocketContext() { + const context = useContext(WebSocketContext); + if (context === undefined) { + throw new Error("useWebSocket must be used within a WebSocketProvider"); + } + return context; +} diff --git a/client/src/game/BoardDisplay.tsx b/client/src/game/BoardDisplay.tsx new file mode 100644 index 0000000..e5a1922 --- /dev/null +++ b/client/src/game/BoardDisplay.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from "react"; +import { useGameChannelContext } from "../contexts/useGameChannelContext"; + +interface Player { + x: number; + y: number; +} + +interface GameState { + [playerId: string]: Player; +} + +export const BoardDisplay = () => { + const { channel, isJoined } = useGameChannelContext(); + const [players, setPlayers] = useState({}); + const [myPlayerId, setMyPlayerId] = useState(null); + + useEffect(() => { + if (!channel || !isJoined) return; + + // Listen for game state updates + const ref = channel.on("game_state", (payload: { players: GameState }) => { + setPlayers(payload.players); + + if (!myPlayerId && Object.keys(payload.players).length > 0) { + const playerIds = Object.keys(payload.players); + if (playerIds.length > 0) { + setMyPlayerId(playerIds[playerIds.length - 1]); + } + } + }); + + // Cleanup listener on unmount + return () => { + channel.off("game_state", ref); + }; + }, [channel, isJoined, myPlayerId]); + + return ( +
+ {Object.entries(players).map(([id, player]) => ( +
+ {id === myPlayerId && ( +
+ You +
+ )} +
+ ))} +
+ ); +}; diff --git a/client/src/game/ConnectionStatus.tsx b/client/src/game/ConnectionStatus.tsx new file mode 100644 index 0000000..6fb9997 --- /dev/null +++ b/client/src/game/ConnectionStatus.tsx @@ -0,0 +1,26 @@ +import { useWebSocketContext } from "../contexts/useWebSocketContext"; +import { useGameChannelContext } from "../contexts/useGameChannelContext"; + +export const ConnectionStatus = () => { + const { connectionStatus } = useWebSocketContext(); + const { channelStatus } = useGameChannelContext(); + + return ( +
+
WebSocket: {connectionStatus}
+
Channel: {channelStatus}
+
+ ); +}; diff --git a/client/src/game/UserInput.tsx b/client/src/game/UserInput.tsx new file mode 100644 index 0000000..be3e41d --- /dev/null +++ b/client/src/game/UserInput.tsx @@ -0,0 +1,45 @@ +import { useEffect, useRef } from "react"; +import { useGameChannelContext } from "../contexts/useGameChannelContext"; + +export const UserInput = () => { + const { channel } = useGameChannelContext(); + const keysPressed = useRef>(new Set()); + + useEffect(() => { + if (!channel) return; + + const handleKeyDown = (e: KeyboardEvent) => { + const key = e.key.toLowerCase(); + if (["w", "a", "s", "d"].includes(key)) { + e.preventDefault(); + + // Only send if not already pressed (prevent repeat) + if (!keysPressed.current.has(key)) { + keysPressed.current.add(key); + + // Send to active channel + if (channel.state === "joined") { + channel.push("move", { direction: key }); + } + } + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + const key = e.key.toLowerCase(); + if (["w", "a", "s", "d"].includes(key)) { + keysPressed.current.delete(key); + } + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, [channel]); + + return null; +}; diff --git a/client/src/main.tsx b/client/src/main.tsx index bef5202..cecf601 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,10 +1,18 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; +import { GameChannelProvider } from "./contexts/GameChannelContext"; +import { WebSocketProvider } from "./contexts/WebSocketContext.tsx"; -createRoot(document.getElementById('root')!).render( +const WS_SERVER = "ws://localhost:4000/socket"; + +createRoot(document.getElementById("root")!).render( - + + + + + , -) +); diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 3c3a5a8..0000000 --- a/flake.lock +++ /dev/null @@ -1,61 +0,0 @@ -{ - "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1771369470, - "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "0182a361324364ae3f436a63005877674cf45efb", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 38047f5..0000000 --- a/flake.nix +++ /dev/null @@ -1,39 +0,0 @@ -{ - description = "Development environment with pnpm and Elixir"; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; - }; - - outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachDefaultSystem (system: - let - pkgs = nixpkgs.legacyPackages.${system}; - in - { - devShells.default = pkgs.mkShell { - buildInputs = with pkgs; [ - # Elixir and Erlang - elixir - elixir-ls - - # Node.js and pnpm - nodejs_22 - pnpm - - # Additional tools - git - inotify-tools - ]; - - shellHook = '' - echo "🚀 Development environment loaded" - echo "Elixir version: $(elixir --version | head -n 1)" - echo "Node.js version: $(node --version)" - echo "pnpm version: $(pnpm --version)" - ''; - }; - } - ); -} diff --git a/nginx-lb.conf b/nginx-lb.conf index 83879ea..56ae08c 100644 --- a/nginx-lb.conf +++ b/nginx-lb.conf @@ -3,11 +3,9 @@ upstream phoenix_backend { # Note: Each new connection gets a new key, so reconnections may route differently hash $http_sec_websocket_key consistent; - # Failover configuration: mark server as down after 3 failed attempts within 30s - # Server will be retried after 30s - server phoenix1:4000 max_fails=1 fail_timeout=30s; - server phoenix2:4000 max_fails=1 fail_timeout=30s; - server phoenix3:4000 max_fails=1 fail_timeout=30s; + server phoenix1:4000 max_fails=1 fail_timeout=10s; + server phoenix2:4000 max_fails=1 fail_timeout=10s; + server phoenix3:4000 max_fails=1 fail_timeout=10s; } server {