From 3b5a26ae82f11beec4fa0da67e0c7dd8117c28f1 Mon Sep 17 00:00:00 2001 From: bzhang625 Date: Wed, 1 Apr 2026 20:32:00 -0400 Subject: [PATCH 1/6] feat: add core bird game mechanics --- eslint.config.mjs | 31 ++-- src/components/games/bird/BirdGame.tsx | 227 +++++++++++++++++++++++++ src/pages/games/bird.tsx | 6 + src/pages/games/index.tsx | 5 + 4 files changed, 252 insertions(+), 17 deletions(-) create mode 100644 src/components/games/bird/BirdGame.tsx create mode 100644 src/pages/games/bird.tsx diff --git a/eslint.config.mjs b/eslint.config.mjs index 45f0382b..aad8e36a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,22 +1,19 @@ -import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; -import nextTypeScript from "eslint-config-next/typescript"; +import { FlatCompat } from "@eslint/eslintrc"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; import prettier from "eslint-config-prettier/flat"; -const config = [ - ...nextCoreWebVitals, - ...nextTypeScript, - { - files: ["**/*.{js,jsx,mjs,ts,tsx,mts,cts}"], - settings: { - react: { - version: "19.2", - }, - }, - }, +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +export default [ + ...compat.extends("next/core-web-vitals", "next/typescript"), { - ignores: [".netlify/**"], + ignores: [".netlify/**", ".next/**"], }, prettier, -]; - -export default config; +]; \ No newline at end of file diff --git a/src/components/games/bird/BirdGame.tsx b/src/components/games/bird/BirdGame.tsx new file mode 100644 index 00000000..218583b7 --- /dev/null +++ b/src/components/games/bird/BirdGame.tsx @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { GameState, type GameWrapperControls } from "../GameWrapper"; + +type Pipe = { + id: number; + x: number; + gapY: number; //where gap starts from top +}; + +const BIRD_SIZE = 40; +const BIRD_X = 80; +const GRAVITY = 0.6; +const FLAP_STRENGTH = -8; +const TICK_MS = 16; +const PIPE_WIDTH = 55; +const PIPE_GAP = 140; +const PIPE_SPEED = 4; +const PIPE_SPAWN = 50; + +export default function BirdGame({ + setSpeechText, + gameState, + setGameState, +}: GameWrapperControls) { + const boardRef = useRef(null); + const spawnCounter = useRef(0); + + const [boardHeight, setBoardHeight] = useState(0); + const [birdY, setBirdY] = useState(0); + const [, setBirdVelocity] = useState(0); + const [pipes, setPipes] = useState([]); + + const resetGame = (height = boardHeight) => { + if (height > 0) { + setBirdY(height * 0.25); + } + setBirdVelocity(0); + setPipes([]); + spawnCounter.current = 0; + }; + + const handleStartOrReplay = () => { + resetGame(); + setGameState(GameState.PLAYING); + }; + + const spawnPipe = useCallback(() => { + if (boardHeight <= PIPE_GAP) return; + + const gapY = Math.random() * (boardHeight - PIPE_GAP); + + const newPipe: Pipe = { + id: Date.now(), + x: boardRef.current?.clientWidth || 400, + gapY, + }; + + setPipes((prev) => [...prev, newPipe]); + }, [boardHeight]); + + useEffect(() => { + if (gameState === GameState.START && boardHeight > 0) { + setBirdY(boardHeight * 0.25); + setBirdVelocity(0); + setPipes([]); + spawnCounter.current = 0; + } + }, [gameState, boardHeight]); + + useEffect(() => { + const measureBoard = () => { + if (!boardRef.current) return; + const height = boardRef.current.clientHeight; + setBoardHeight(height); + }; + + measureBoard(); + window.addEventListener("resize", measureBoard); + + return () => { + window.removeEventListener("resize", measureBoard); + }; + }, []); + + useEffect(() => { + if (gameState === GameState.START) { + setSpeechText("Press start, then space to flap!"); + return; + } + + if (gameState === GameState.PLAYING) { + setSpeechText("Press space to flap!"); + return; + } + + if (gameState === GameState.WON) { + setSpeechText("Amazing! You have won!"); + return; + } + + setSpeechText("Nice try!"); + }, [gameState, setSpeechText]); + + useEffect(() => { + if (gameState !== GameState.PLAYING || boardHeight === 0) return; + + const intervalId = window.setInterval(() => { + setPipes( + (prevPipes) => + prevPipes + .map((pipe) => ({ + ...pipe, + x: pipe.x - PIPE_SPEED, + })) + .filter((pipe) => pipe.x + PIPE_WIDTH > 0), // remove off screen + ); + + spawnCounter.current += 1; + if (spawnCounter.current >= PIPE_SPAWN) { + spawnPipe(); + spawnCounter.current = 0; + } + + setBirdVelocity((prevVelocity) => { + const newVelocity = prevVelocity + GRAVITY; + + setBirdY((prevY) => { + const newY = prevY + newVelocity; + + if (newY <= 0) { + setGameState(GameState.LOSS); + return 0; + } + + if (newY + BIRD_SIZE >= boardHeight) { + setGameState(GameState.LOSS); + return boardHeight - BIRD_SIZE; + } + + return newY; + }); + + return newVelocity; + }); + }, TICK_MS); + + return () => { + window.clearInterval(intervalId); + }; + }, [gameState, boardHeight, setGameState, spawnPipe]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.code !== "Space") return; + + event.preventDefault(); + + if (gameState === GameState.PLAYING) { + setBirdVelocity(FLAP_STRENGTH); + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [gameState]); + + return ( +
+
+ {/* Bird */} +
+ + {/* Pipes */} + {pipes.map((pipe) => ( +
+ {/* Top pipe */} +
+ + {/* Bottom pipe */} +
+
+ ))} + + {gameState !== GameState.PLAYING && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/pages/games/bird.tsx b/src/pages/games/bird.tsx new file mode 100644 index 00000000..54db505d --- /dev/null +++ b/src/pages/games/bird.tsx @@ -0,0 +1,6 @@ +import GameWrapper from "@/components/games/GameWrapper"; +import BirdGame from "@/components/games/bird/BirdGame"; + +export default function BirdGamePage() { + return ; +} diff --git a/src/pages/games/index.tsx b/src/pages/games/index.tsx index 72663f6f..6e01f238 100644 --- a/src/pages/games/index.tsx +++ b/src/pages/games/index.tsx @@ -24,6 +24,11 @@ export default function GamesIndex() { icon: "/icons/Star.svg", href: "/games/trivia", }, + { + name: "Bird Game", + icon: "/icons/Star.svg", + href: "/games/bird", + }, ]; return ( From ca0aac311da3f4bba2cb5280abe33d0011266698 Mon Sep 17 00:00:00 2001 From: Joshua Forden <26302898+air4den@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:45:12 -0400 Subject: [PATCH 2/6] fix: tweak physics constants --- src/components/games/bird/BirdGame.tsx | 16 ++++++++-------- tsconfig.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/games/bird/BirdGame.tsx b/src/components/games/bird/BirdGame.tsx index 218583b7..29de6351 100644 --- a/src/components/games/bird/BirdGame.tsx +++ b/src/components/games/bird/BirdGame.tsx @@ -7,15 +7,15 @@ type Pipe = { gapY: number; //where gap starts from top }; -const BIRD_SIZE = 40; -const BIRD_X = 80; -const GRAVITY = 0.6; -const FLAP_STRENGTH = -8; +const BIRD_SIZE = 30; +const BIRD_X = 60; +const GRAVITY = 0.35; +const FLAP_STRENGTH = -6; const TICK_MS = 16; -const PIPE_WIDTH = 55; -const PIPE_GAP = 140; -const PIPE_SPEED = 4; -const PIPE_SPAWN = 50; +const PIPE_WIDTH = 50; +const PIPE_GAP = 175; +const PIPE_SPEED = 3; +const PIPE_SPAWN = 120; export default function BirdGame({ setSpeechText, diff --git a/tsconfig.json b/tsconfig.json index 3af314a1..90511079 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "paths": { "@/*": [ From 7aed9983bc30a062afdd895f4b554d2575e152e5 Mon Sep 17 00:00:00 2001 From: Joshua Forden <26302898+air4den@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:14:32 -0400 Subject: [PATCH 3/6] feat: flappy bird score --- src/components/games/bird/BirdGame.tsx | 63 +++++++++++++++++++++----- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/src/components/games/bird/BirdGame.tsx b/src/components/games/bird/BirdGame.tsx index 29de6351..b7f5a2a9 100644 --- a/src/components/games/bird/BirdGame.tsx +++ b/src/components/games/bird/BirdGame.tsx @@ -4,7 +4,8 @@ import { GameState, type GameWrapperControls } from "../GameWrapper"; type Pipe = { id: number; x: number; - gapY: number; //where gap starts from top + gapY: number; // where gap starts from top + scored: boolean; // true once the bird has passed this pipe }; const BIRD_SIZE = 30; @@ -29,6 +30,7 @@ export default function BirdGame({ const [birdY, setBirdY] = useState(0); const [, setBirdVelocity] = useState(0); const [pipes, setPipes] = useState([]); + const [score, setScore] = useState(0); const resetGame = (height = boardHeight) => { if (height > 0) { @@ -36,6 +38,7 @@ export default function BirdGame({ } setBirdVelocity(0); setPipes([]); + setScore(0); spawnCounter.current = 0; }; @@ -53,6 +56,7 @@ export default function BirdGame({ id: Date.now(), x: boardRef.current?.clientWidth || 400, gapY, + scored: false, }; setPipes((prev) => [...prev, newPipe]); @@ -63,6 +67,7 @@ export default function BirdGame({ setBirdY(boardHeight * 0.25); setBirdVelocity(0); setPipes([]); + setScore(0); spawnCounter.current = 0; } }, [gameState, boardHeight]); @@ -105,15 +110,26 @@ export default function BirdGame({ if (gameState !== GameState.PLAYING || boardHeight === 0) return; const intervalId = window.setInterval(() => { - setPipes( - (prevPipes) => - prevPipes - .map((pipe) => ({ - ...pipe, - x: pipe.x - PIPE_SPEED, - })) - .filter((pipe) => pipe.x + PIPE_WIDTH > 0), // remove off screen - ); + // Move pipes, check scoring; capture updated positions for collision below + let movedPipes: Pipe[] = []; + setPipes((prevPipes) => { + let pointScored = false; + + const updated = prevPipes + .map((pipe) => { + const newX = pipe.x - PIPE_SPEED; + const justPassed = + !pipe.scored && BIRD_X > pipe.x + PIPE_WIDTH - PIPE_SPEED; + if (justPassed) pointScored = true; + return { ...pipe, x: newX, scored: pipe.scored || justPassed }; + }) + .filter((pipe) => pipe.x + PIPE_WIDTH > 0); + + if (pointScored) setScore((s) => s + 1); + + movedPipes = updated; + return updated; + }); spawnCounter.current += 1; if (spawnCounter.current >= PIPE_SPAWN) { @@ -137,6 +153,25 @@ export default function BirdGame({ return boardHeight - BIRD_SIZE; } + // Collision: check bird AABB against each pipe using true current positions + const birdLeft = BIRD_X; + const birdRight = BIRD_X + BIRD_SIZE; + const birdTop = newY; + const birdBottom = newY + BIRD_SIZE; + + for (const pipe of movedPipes) { + const horizontalOverlap = + birdRight > pipe.x && birdLeft < pipe.x + PIPE_WIDTH; + if (horizontalOverlap) { + const hitsTop = birdTop < pipe.gapY; + const hitsBottom = birdBottom > pipe.gapY + PIPE_GAP; + if (hitsTop || hitsBottom) { + setGameState(GameState.LOSS); + return prevY; + } + } + } + return newY; }); @@ -171,8 +206,14 @@ export default function BirdGame({
+ {gameState === GameState.PLAYING && ( +
+ {score} +
+ )} + {/* Bird */}
Date: Thu, 2 Apr 2026 19:20:16 -0400 Subject: [PATCH 4/6] fix: bird new Y --- src/components/games/bird/BirdGame.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/games/bird/BirdGame.tsx b/src/components/games/bird/BirdGame.tsx index b7f5a2a9..2ab91881 100644 --- a/src/components/games/bird/BirdGame.tsx +++ b/src/components/games/bird/BirdGame.tsx @@ -167,7 +167,7 @@ export default function BirdGame({ const hitsBottom = birdBottom > pipe.gapY + PIPE_GAP; if (hitsTop || hitsBottom) { setGameState(GameState.LOSS); - return prevY; + return newY; } } } From 99b2d7d84f8400b50b791f50737a60f0d5b7cd9e Mon Sep 17 00:00:00 2001 From: Joshua Forden <26302898+air4den@users.noreply.github.com> Date: Fri, 3 Apr 2026 07:38:09 -0400 Subject: [PATCH 5/6] feat: dynamic sizing + backend highScore --- .../[userId]/game-statistics/record/route.ts | 9 +- src/components/games/bird/BirdGame.tsx | 105 +++++++++++------- .../games/flowerman/FlowermanFlower.tsx | 2 +- .../games/flowerman/FlowermanGame.tsx | 2 +- .../games/flowerman/FlowermanKeyboard.tsx | 2 +- .../flowermanConstants.ts | 0 src/db/actions/gameStatistics.ts | 6 + src/services/gameStatistics.ts | 5 +- src/types/games.ts | 2 + src/utils/serviceUtils/gameStatisticsUtil.ts | 1 + 10 files changed, 85 insertions(+), 49 deletions(-) rename src/{constant => constants}/flowermanConstants.ts (100%) diff --git a/src/app/api/v1/users/[userId]/game-statistics/record/route.ts b/src/app/api/v1/users/[userId]/game-statistics/record/route.ts index 8de5a8e0..6cee0f78 100644 --- a/src/app/api/v1/users/[userId]/game-statistics/record/route.ts +++ b/src/app/api/v1/users/[userId]/game-statistics/record/route.ts @@ -13,9 +13,14 @@ export const POST = withAuth<{ userId: string }>( ) => { const { userId } = params; verifyUser(tokenUser, userId, ERRORS.GAME_STATISTICS.UNAUTHORIZED); - const { gameName, result } = await req.json(); + const { gameName, result, score } = await req.json(); - await GameStatisticsService.recordGameResult(userId, gameName, result); + await GameStatisticsService.recordGameResult( + userId, + gameName, + result, + score, + ); return NextResponse.json({}, { status: 201 }); }, ); diff --git a/src/components/games/bird/BirdGame.tsx b/src/components/games/bird/BirdGame.tsx index 2ab91881..f2d560fc 100644 --- a/src/components/games/bird/BirdGame.tsx +++ b/src/components/games/bird/BirdGame.tsx @@ -8,13 +8,13 @@ type Pipe = { scored: boolean; // true once the bird has passed this pipe }; -const BIRD_SIZE = 30; -const BIRD_X = 60; +const BIRD_SIZE_RATIO = 0.07; // fraction of board height +const BIRD_X_RATIO = 0.12; // fraction of board width const GRAVITY = 0.35; const FLAP_STRENGTH = -6; const TICK_MS = 16; -const PIPE_WIDTH = 50; -const PIPE_GAP = 175; +const PIPE_WIDTH_RATIO = 0.09; // fraction of board width +const PIPE_GAP_RATIO = 0.42; // fraction of board height const PIPE_SPEED = 3; const PIPE_SPAWN = 120; @@ -27,30 +27,40 @@ export default function BirdGame({ const spawnCounter = useRef(0); const [boardHeight, setBoardHeight] = useState(0); + const [boardWidth, setBoardWidth] = useState(0); const [birdY, setBirdY] = useState(0); const [, setBirdVelocity] = useState(0); const [pipes, setPipes] = useState([]); const [score, setScore] = useState(0); - const resetGame = (height = boardHeight) => { - if (height > 0) { - setBirdY(height * 0.25); - } - setBirdVelocity(0); - setPipes([]); - setScore(0); - spawnCounter.current = 0; - }; + const resetGame = useCallback( + (height = boardHeight) => { + if (height > 0) { + setBirdY(height * 0.25); + } + setBirdVelocity(0); + setPipes([]); + setScore(0); + spawnCounter.current = 0; + }, + [boardHeight], + ); const handleStartOrReplay = () => { resetGame(); setGameState(GameState.PLAYING); }; + const birdSize = boardHeight * BIRD_SIZE_RATIO; + const birdX = boardWidth * BIRD_X_RATIO; + const pipeWidth = boardWidth * PIPE_WIDTH_RATIO; + const pipeGap = boardHeight * PIPE_GAP_RATIO; + const spawnPipe = useCallback(() => { - if (boardHeight <= PIPE_GAP) return; + const gap = boardHeight * PIPE_GAP_RATIO; + if (boardHeight <= gap) return; - const gapY = Math.random() * (boardHeight - PIPE_GAP); + const gapY = Math.random() * (boardHeight - gap); const newPipe: Pipe = { id: Date.now(), @@ -64,19 +74,15 @@ export default function BirdGame({ useEffect(() => { if (gameState === GameState.START && boardHeight > 0) { - setBirdY(boardHeight * 0.25); - setBirdVelocity(0); - setPipes([]); - setScore(0); - spawnCounter.current = 0; + resetGame(); } - }, [gameState, boardHeight]); + }, [gameState, boardHeight, resetGame]); useEffect(() => { const measureBoard = () => { if (!boardRef.current) return; - const height = boardRef.current.clientHeight; - setBoardHeight(height); + setBoardHeight(boardRef.current.clientHeight); + setBoardWidth(boardRef.current.clientWidth); }; measureBoard(); @@ -109,6 +115,11 @@ export default function BirdGame({ useEffect(() => { if (gameState !== GameState.PLAYING || boardHeight === 0) return; + const activeBirdSize = boardHeight * BIRD_SIZE_RATIO; + const activeBirdX = boardWidth * BIRD_X_RATIO; + const activePipeWidth = boardWidth * PIPE_WIDTH_RATIO; + const activePipeGap = boardHeight * PIPE_GAP_RATIO; + const intervalId = window.setInterval(() => { // Move pipes, check scoring; capture updated positions for collision below let movedPipes: Pipe[] = []; @@ -119,11 +130,12 @@ export default function BirdGame({ .map((pipe) => { const newX = pipe.x - PIPE_SPEED; const justPassed = - !pipe.scored && BIRD_X > pipe.x + PIPE_WIDTH - PIPE_SPEED; + !pipe.scored && + activeBirdX > pipe.x + activePipeWidth - PIPE_SPEED; if (justPassed) pointScored = true; return { ...pipe, x: newX, scored: pipe.scored || justPassed }; }) - .filter((pipe) => pipe.x + PIPE_WIDTH > 0); + .filter((pipe) => pipe.x + activePipeWidth > 0); if (pointScored) setScore((s) => s + 1); @@ -148,23 +160,23 @@ export default function BirdGame({ return 0; } - if (newY + BIRD_SIZE >= boardHeight) { + if (newY + activeBirdSize >= boardHeight) { setGameState(GameState.LOSS); - return boardHeight - BIRD_SIZE; + return boardHeight - activeBirdSize; } // Collision: check bird AABB against each pipe using true current positions - const birdLeft = BIRD_X; - const birdRight = BIRD_X + BIRD_SIZE; + const birdLeft = activeBirdX; + const birdRight = activeBirdX + activeBirdSize; const birdTop = newY; - const birdBottom = newY + BIRD_SIZE; + const birdBottom = newY + activeBirdSize; for (const pipe of movedPipes) { const horizontalOverlap = - birdRight > pipe.x && birdLeft < pipe.x + PIPE_WIDTH; + birdRight > pipe.x && birdLeft < pipe.x + activePipeWidth; if (horizontalOverlap) { const hitsTop = birdTop < pipe.gapY; - const hitsBottom = birdBottom > pipe.gapY + PIPE_GAP; + const hitsBottom = birdBottom > pipe.gapY + activePipeGap; if (hitsTop || hitsBottom) { setGameState(GameState.LOSS); return newY; @@ -182,7 +194,7 @@ export default function BirdGame({ return () => { window.clearInterval(intervalId); }; - }, [gameState, boardHeight, setGameState, spawnPipe]); + }, [gameState, boardHeight, boardWidth, setGameState, spawnPipe]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -202,13 +214,17 @@ export default function BirdGame({ }; }, [gameState]); + const isGameOver = + gameState === GameState.LOSS || gameState === GameState.WON; + return (
- {gameState === GameState.PLAYING && ( + {/* Score — visible while playing and on the end screen */} + {(gameState === GameState.PLAYING || isGameOver) && (
{score}
@@ -219,9 +235,9 @@ export default function BirdGame({ className="absolute rounded-full bg-yellow-400" style={{ top: `${birdY}px`, - left: `${BIRD_X}px`, - width: `${BIRD_SIZE}px`, - height: `${BIRD_SIZE}px`, + left: `${birdX}px`, + width: `${birdSize}px`, + height: `${birdSize}px`, }} /> @@ -234,7 +250,7 @@ export default function BirdGame({ style={{ left: `${pipe.x}px`, top: 0, - width: `${PIPE_WIDTH}px`, + width: `${pipeWidth}px`, height: `${pipe.gapY}px`, }} /> @@ -244,16 +260,21 @@ export default function BirdGame({ className="absolute bg-green-500" style={{ left: `${pipe.x}px`, - top: `${pipe.gapY + PIPE_GAP}px`, - width: `${PIPE_WIDTH}px`, - height: `${boardHeight - (pipe.gapY + PIPE_GAP)}px`, + top: `${pipe.gapY + pipeGap}px`, + width: `${pipeWidth}px`, + height: `${boardHeight - (pipe.gapY + pipeGap)}px`, }} />
))} {gameState !== GameState.PLAYING && ( -
+
+ {isGameOver && ( +

+ Score: {score} +

+ )}