diff --git a/client/dev.Dockerfile b/client/dev.Dockerfile new file mode 100644 index 0000000..fb17acb --- /dev/null +++ b/client/dev.Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine + +RUN apk add --no-cache bash + +WORKDIR /app + +RUN npm install -g pnpm + +ENV USER="node" + +RUN addgroup -g 1000 $USER && \ + adduser -D -u 1000 -G $USER $USER || true + +RUN chown -R node:node /app + +USER node + +EXPOSE 5173 + +CMD ["pnpm", "dev", "--host"] diff --git a/client/src/App.tsx b/client/src/App.tsx index c355dd0..d8afc45 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,12 +1,27 @@ +import { useState } from "react"; import "./App.css"; import { UserInput } from "./game/UserInput"; import { BoardDisplay } from "./game/BoardDisplay"; import { ConnectionStatus } from "./game/ConnectionStatus"; +import { NameInput } from "./game/NameInput"; + +const getPlayerNameFromUrl = () => { + const params = new URLSearchParams(window.location.search); + return params.get("name") || null; +}; function App() { + const [playerName, setPlayerName] = useState( + getPlayerNameFromUrl, + ); + + if (!playerName) { + return ; + } + return ( <> - +
- +
); diff --git a/client/src/contexts/GameChannelContext.tsx b/client/src/contexts/GameChannelContext.tsx index 1388c37..edc5233 100644 --- a/client/src/contexts/GameChannelContext.tsx +++ b/client/src/contexts/GameChannelContext.tsx @@ -6,6 +6,7 @@ interface GameChannelContextValue { channel: Channel | null; channelStatus: string; isJoined: boolean; + joinGame: (name: string) => void; } const GameChannelContext = createContext( @@ -40,18 +41,15 @@ export function GameChannelProvider({ .join() .receive("ok", () => { console.log(`✓ Joined channel: ${channelName}`); - setChannelStatus("joined"); - setIsJoined(true); + setChannelStatus("connected"); }) .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 @@ -62,15 +60,34 @@ export function GameChannelProvider({ newChannel.leave(); setChannel(null); setIsJoined(false); + setChannelStatus("waiting"); }; }, [socket, isConnected, channelName, params]); + const joinGame = (name: string) => { + if (!channel) return; + + channel + .push("join_game", { name }) + .receive("ok", () => { + console.log(`✓ Joined game as: ${name}`); + setChannelStatus("joined"); + setIsJoined(true); + }) + .receive("error", (resp: unknown) => { + console.log(`✗ Failed to join game:`, resp); + setChannelStatus("join failed"); + setIsJoined(false); + }); + }; + return ( {children} diff --git a/client/src/game/BoardDisplay.tsx b/client/src/game/BoardDisplay.tsx index e5a1922..1ad1359 100644 --- a/client/src/game/BoardDisplay.tsx +++ b/client/src/game/BoardDisplay.tsx @@ -7,34 +7,35 @@ interface Player { } interface GameState { - [playerId: string]: Player; + [playerName: string]: Player; } -export const BoardDisplay = () => { - const { channel, isJoined } = useGameChannelContext(); +export const BoardDisplay = ({ playerName }: { + playerName: string; +}) => { + const { channel, isJoined, joinGame } = useGameChannelContext(); const [players, setPlayers] = useState({}); - const [myPlayerId, setMyPlayerId] = useState(null); useEffect(() => { - if (!channel || !isJoined) return; + if (channel && !isJoined) { + // Send join_game message with player name + joinGame(playerName); + } + }, [channel, isJoined, playerName, joinGame]); + + useEffect(() => { + if (!channel) 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]); + }, [channel]); return (
{ overflow: "hidden", }} > - {Object.entries(players).map(([id, player]) => ( + {Object.entries(players).map(([name, player]) => (
{ width: "20px", height: "20px", borderRadius: "50%", - background: id === myPlayerId ? "#e94560" : "#53a8b6", + background: name === playerName ? "#e94560" : "#53a8b6", border: - id === myPlayerId ? "3px solid #ff6b6b" : "2px solid #48d6e0", + name === playerName ? "3px solid #ff6b6b" : "2px solid #48d6e0", transition: "all 0.1s linear", transform: "translate(-50%, -50%)", boxShadow: - id === myPlayerId ? "0 0 10px #e94560" : "0 0 5px #53a8b6", + name === playerName ? "0 0 10px #e94560" : "0 0 5px #53a8b6", }} > - {id === myPlayerId && ( -
- You -
- )} +
+ {name} +
))}
diff --git a/client/src/game/NameInput.tsx b/client/src/game/NameInput.tsx new file mode 100644 index 0000000..4fddbe2 --- /dev/null +++ b/client/src/game/NameInput.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; + +interface NameInputProps { + onNameSubmit: (name: string) => void; +} + +const getNameFromUrl = () => { + const params = new URLSearchParams(window.location.search); + return params.get("name") || ""; +}; + +export const NameInput = ({ onNameSubmit }: NameInputProps) => { + const [name, setName] = useState(getNameFromUrl); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (name.trim()) { + // Update URL with name + const params = new URLSearchParams(window.location.search); + params.set("name", name.trim()); + window.history.replaceState( + {}, + "", + `${window.location.pathname}?${params}`, + ); + + onNameSubmit(name.trim()); + } + }; + + return ( +
+
+

+ Enter Your Name +

+
+ setName(e.target.value)} + placeholder="Your name..." + autoFocus + style={{ + width: "100%", + padding: "12px", + fontSize: "16px", + background: "#0f3460", + border: "2px solid #53a8b6", + borderRadius: "5px", + color: "white", + fontFamily: "monospace", + marginBottom: "20px", + boxSizing: "border-box", + }} + /> + +
+
+
+ ); +}; diff --git a/client/src/game/UserInput.tsx b/client/src/game/UserInput.tsx index be3e41d..d68ef6e 100644 --- a/client/src/game/UserInput.tsx +++ b/client/src/game/UserInput.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { useGameChannelContext } from "../contexts/useGameChannelContext"; -export const UserInput = () => { +export const UserInput = ({ playerName }: { playerName: string }) => { const { channel } = useGameChannelContext(); const keysPressed = useRef>(new Set()); @@ -12,16 +12,7 @@ export const UserInput = () => { 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 }); - } - } + keysPressed.current.add(key); } }; @@ -32,14 +23,28 @@ export const UserInput = () => { } }; + const handleBlur = () => { + keysPressed.current.clear(); + }; + + const interval = setInterval(() => { + if (keysPressed.current.size > 0 && channel.state === "joined") { + const directions = Array.from(keysPressed.current); + channel.push("move", { directions, name: playerName }); + } + }, 100); + window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); return () => { + clearInterval(interval); window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); }; - }, [channel]); + }, [channel, playerName]); return null; }; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..2b1a5b4 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,109 @@ +services: + phoenix1: + build: + context: ./backend + dockerfile: Dockerfile + container_name: phoenix1 + hostname: phoenix1 + environment: + - RELEASE_NODE=backend@phoenix1 + - RELEASE_DISTRIBUTION=sname + - RELEASE_COOKIE=super_secret_cookie_change_in_production + - PHX_HOST=localhost + - PHX_SERVER=true + - PORT=4000 + - DATABASE_URL=ecto://postgres:postgres@db/backend_dev + - SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv + - CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3 + - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 + ports: + - "4001:4000" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"] + interval: 10s + timeout: 5s + retries: 5 + + phoenix2: + build: + context: ./backend + dockerfile: Dockerfile + container_name: phoenix2 + hostname: phoenix2 + environment: + - RELEASE_NODE=backend@phoenix2 + - RELEASE_DISTRIBUTION=sname + - RELEASE_COOKIE=super_secret_cookie_change_in_production + - PHX_HOST=localhost + - PHX_SERVER=true + - PORT=4000 + - DATABASE_URL=ecto://postgres:postgres@db/backend_dev + - SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv + - CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3 + - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 + ports: + - "4002:4000" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"] + interval: 10s + timeout: 5s + retries: 5 + + phoenix3: + build: + context: ./backend + dockerfile: Dockerfile + container_name: phoenix3 + hostname: phoenix3 + environment: + - RELEASE_NODE=backend@phoenix3 + - RELEASE_DISTRIBUTION=sname + - RELEASE_COOKIE=super_secret_cookie_change_in_production + - PHX_HOST=localhost + - PHX_SERVER=true + - PORT=4000 + - DATABASE_URL=ecto://postgres:postgres@db/backend_dev + - SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv + - CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3 + - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 + ports: + - "4003:4000" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"] + interval: 10s + timeout: 5s + retries: 5 + + nginx-lb: + image: nginx:alpine + container_name: nginx-lb + volumes: + - ./nginx-lb.conf:/etc/nginx/conf.d/default.conf + ports: + - "4000:80" + depends_on: + - phoenix1 + - phoenix2 + - phoenix3 + + client: + build: + context: ./client + dockerfile: Dockerfile + container_name: client + ports: + - "5173:80" + depends_on: + - nginx-lb + + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + container_name: otel-collector + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + ports: + - "4318:4318" # OTLP HTTP receiver + - "4317:4317" # OTLP gRPC receiver + - "8888:8888" # Prometheus metrics + - "8889:8889" # Prometheus exporter metrics diff --git a/docker-compose.yml b/docker-compose.yml index 0b4061f..90a6f51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,26 @@ -version: "3.8" - services: phoenix1: build: context: ./backend - dockerfile: Dockerfile + dockerfile: dev.Dockerfile container_name: phoenix1 hostname: phoenix1 environment: - - RELEASE_NODE=backend@phoenix1 - - RELEASE_DISTRIBUTION=sname - - RELEASE_COOKIE=super_secret_cookie_change_in_production - - PHX_HOST=localhost - PHX_SERVER=true - PORT=4000 - - DATABASE_URL=ecto://postgres:postgres@db/backend_dev - - SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv + - MIX_ENV=dev + - NODE_NAME=backend@phoenix1 + - COOKIE=dev_cookie - CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3 - - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 - ports: - - "4001:4000" + user: root + command: | + sh -c ' + chown -R elixir:elixir /app/_build + su elixir -c "elixir --sname $${NODE_NAME} --cookie $${COOKIE} -S mix phx.server" + ' + volumes: + - ./backend:/app + - /app/_build healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"] interval: 10s @@ -29,22 +30,25 @@ services: phoenix2: build: context: ./backend - dockerfile: Dockerfile + dockerfile: dev.Dockerfile container_name: phoenix2 hostname: phoenix2 environment: - - RELEASE_NODE=backend@phoenix2 - - RELEASE_DISTRIBUTION=sname - - RELEASE_COOKIE=super_secret_cookie_change_in_production - - PHX_HOST=localhost - PHX_SERVER=true - PORT=4000 - - DATABASE_URL=ecto://postgres:postgres@db/backend_dev - - SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv + - MIX_ENV=dev + - NODE_NAME=backend@phoenix2 + - COOKIE=dev_cookie - CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3 - - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 - ports: - - "4002:4000" + user: root + command: | + sh -c ' + chown -R elixir:elixir /app/_build + su elixir -c "elixir --sname $${NODE_NAME} --cookie $${COOKIE} -S mix phx.server" + ' + volumes: + - ./backend:/app + - /app/_build healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"] interval: 10s @@ -54,22 +58,25 @@ services: phoenix3: build: context: ./backend - dockerfile: Dockerfile + dockerfile: dev.Dockerfile container_name: phoenix3 hostname: phoenix3 environment: - - RELEASE_NODE=backend@phoenix3 - - RELEASE_DISTRIBUTION=sname - - RELEASE_COOKIE=super_secret_cookie_change_in_production - - PHX_HOST=localhost - PHX_SERVER=true - PORT=4000 - - DATABASE_URL=ecto://postgres:postgres@db/backend_dev - - SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv + - MIX_ENV=dev + - NODE_NAME=backend@phoenix3 + - COOKIE=dev_cookie - CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3 - - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 - ports: - - "4003:4000" + user: root + command: | + sh -c ' + chown -R elixir:elixir /app/_build + su elixir -c "elixir --sname $${NODE_NAME} --cookie $${COOKIE} -S mix phx.server" + ' + volumes: + - ./backend:/app + - /app/_build healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"] interval: 10s @@ -91,10 +98,12 @@ services: client: build: context: ./client - dockerfile: Dockerfile + dockerfile: dev.Dockerfile container_name: client + volumes: + - ./client:/app ports: - - "5173:80" + - "5173:5173" depends_on: - nginx-lb @@ -104,8 +113,4 @@ services: command: ["--config=/etc/otel-collector-config.yaml"] volumes: - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml - ports: - - "4318:4318" # OTLP HTTP receiver - - "4317:4317" # OTLP gRPC receiver - - "8888:8888" # Prometheus metrics - - "8889:8889" # Prometheus exporter metrics + diff --git a/nginx-lb.conf b/nginx-lb.conf index 56ae08c..9160aa2 100644 --- a/nginx-lb.conf +++ b/nginx-lb.conf @@ -1,6 +1,4 @@ upstream phoenix_backend { - # Hash based on WebSocket handshake key for sticky sessions - # Note: Each new connection gets a new key, so reconnections may route differently hash $http_sec_websocket_key consistent; server phoenix1:4000 max_fails=1 fail_timeout=10s; @@ -22,6 +20,11 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + proxy_next_upstream error timeout invalid_header http_502 http_503 http_504; + proxy_next_upstream_tries 1; + proxy_connect_timeout 1s; + proxy_read_timeout 60s; } location /api { @@ -31,6 +34,11 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + proxy_next_upstream error timeout invalid_header http_502 http_503 http_504; + proxy_next_upstream_tries 3; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; } location /health {