Skip to content

Commit 2399248

Browse files
committed
feat(games): add Border Bingo game
1 parent b5780cd commit 2399248

3 files changed

Lines changed: 309 additions & 0 deletions

File tree

src/App.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import GameDashboard from "./games/GameDashboard";
1010
import GameC from "./games/GameC";
1111
import GameHL from "./games/GameHL";
1212
import Worldle from "./games/Worldle";
13+
import GameBingo from "./games/GameBingo";
1314
import { getOverview } from "./services/api";
1415
export const DataContext = createContext();
1516
export const DarkContext = createContext();
@@ -60,6 +61,7 @@ function App() {
6061
<Route path="/guessthecountry" element={<GameC />} />
6162
<Route path="/higherlower" element={<GameHL />} />
6263
<Route path="/worldle" element={<Worldle />} />
64+
<Route path="/borderbingo" element={<GameBingo />} />
6365
<Route path="*" element={<NotFound />} />
6466
</Routes>
6567
</DataContext.Provider>

src/games/GameBingo.jsx

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import { useContext, useMemo, useState, useCallback } from "react";
2+
import { DataContext } from "../App";
3+
import GameLayout from "./GameLayout";
4+
import Button from "../components/Button";
5+
import usePageMeta from "../hooks/usePageMeta";
6+
import { getRandomInt } from "../utils";
7+
8+
const GRID_SIZE = 3;
9+
10+
let lastHub = null;
11+
12+
function generateBoard(countries) {
13+
const byCode = Object.fromEntries(countries.map((c) => [c.cca3, c]));
14+
const withBorders = countries.filter((c) => c.borders && c.borders.length >= 3);
15+
16+
// Build neighbor sets
17+
const neighbors = {};
18+
for (const c of withBorders) {
19+
neighbors[c.cca3] = new Set(c.borders);
20+
}
21+
22+
// Get all valid hubs, shuffled, avoiding the last used one
23+
const hubs = withBorders
24+
.filter((c) => c.borders.length >= GRID_SIZE * 2 && c.cca3 !== lastHub)
25+
.sort(() => Math.random() - 0.5);
26+
27+
for (const seed of hubs) {
28+
// Get valid neighbors and shuffle them
29+
const seedNeighbors = seed.borders
30+
.map((code) => byCode[code])
31+
.filter((c) => c && c.borders && c.borders.length >= 2)
32+
.sort(() => Math.random() - 0.5);
33+
34+
if (seedNeighbors.length < GRID_SIZE * 2) continue;
35+
36+
// Try multiple row/col splits from this hub's neighbors
37+
for (let split = 0; split < 10; split++) {
38+
const reshuffled = [...seedNeighbors].sort(() => Math.random() - 0.5);
39+
const rows = reshuffled.slice(0, GRID_SIZE);
40+
const cols = reshuffled.slice(GRID_SIZE, GRID_SIZE * 2);
41+
42+
if (rows.some((r) => cols.find((c) => c.cca3 === r.cca3))) continue;
43+
44+
// Validate: for each cell, find countries that border both
45+
const solutions = [];
46+
let valid = true;
47+
for (let r = 0; r < GRID_SIZE; r++) {
48+
solutions[r] = [];
49+
for (let c = 0; c < GRID_SIZE; c++) {
50+
const rowBorders = neighbors[rows[r].cca3] || new Set();
51+
const colBorders = neighbors[cols[c].cca3] || new Set();
52+
const shared = [...rowBorders].filter((code) => colBorders.has(code));
53+
if (shared.length === 0) {
54+
valid = false;
55+
break;
56+
}
57+
solutions[r][c] = shared;
58+
}
59+
if (!valid) break;
60+
}
61+
if (valid) {
62+
lastHub = seed.cca3;
63+
return { rows, cols, solutions, byCode };
64+
}
65+
}
66+
}
67+
return null;
68+
}
69+
70+
export default function GameBingo() {
71+
const data = useContext(DataContext);
72+
73+
usePageMeta(
74+
"Where in the world? - Border Bingo",
75+
"Select countries that share borders with both the row and column countries."
76+
);
77+
78+
const [board, setBoard] = useState(() => generateBoard(data));
79+
const [selected, setSelected] = useState(
80+
Array.from({ length: GRID_SIZE }, () => Array(GRID_SIZE).fill(null))
81+
);
82+
const [revealed, setRevealed] = useState(
83+
Array.from({ length: GRID_SIZE }, () => Array(GRID_SIZE).fill(false))
84+
);
85+
const [gameOver, setGameOver] = useState(false);
86+
const [score, setScore] = useState(0);
87+
88+
// Pool of selectable countries (all with borders, excluding row/col headers)
89+
const pool = useMemo(() => {
90+
if (!board) return [];
91+
const excluded = new Set([
92+
...board.rows.map((c) => c.cca3),
93+
...board.cols.map((c) => c.cca3),
94+
]);
95+
return data
96+
.filter((c) => c.borders && c.borders.length > 0 && !excluded.has(c.cca3))
97+
.sort((a, b) => a.name.common.localeCompare(b.name.common));
98+
}, [board, data]);
99+
100+
const [activeCell, setActiveCell] = useState(null);
101+
const [search, setSearch] = useState("");
102+
103+
const filteredPool = useMemo(() => {
104+
if (!search) return pool.slice(0, 20);
105+
const q = search.toLowerCase();
106+
return pool.filter((c) => c.name.common.toLowerCase().includes(q)).slice(0, 20);
107+
}, [pool, search]);
108+
109+
const handleCellClick = (r, c) => {
110+
if (revealed[r][c] || gameOver) return;
111+
setActiveCell({ r, c });
112+
setSearch("");
113+
};
114+
115+
const handleSelect = (country) => {
116+
if (!activeCell || gameOver) return;
117+
const { r, c } = activeCell;
118+
const isCorrect = board.solutions[r][c].includes(country.cca3);
119+
120+
const newSelected = selected.map((row) => [...row]);
121+
newSelected[r][c] = { country, correct: isCorrect };
122+
setSelected(newSelected);
123+
124+
const newRevealed = revealed.map((row) => [...row]);
125+
newRevealed[r][c] = true;
126+
setRevealed(newRevealed);
127+
128+
if (isCorrect) setScore((s) => s + 1);
129+
setActiveCell(null);
130+
setSearch("");
131+
132+
// Check if game is over
133+
const totalRevealed = newRevealed.flat().filter(Boolean).length;
134+
if (totalRevealed === GRID_SIZE * GRID_SIZE) setGameOver(true);
135+
};
136+
137+
const restart = () => {
138+
const newBoard = generateBoard(data);
139+
setBoard(newBoard);
140+
setSelected(Array.from({ length: GRID_SIZE }, () => Array(GRID_SIZE).fill(null)));
141+
setRevealed(Array.from({ length: GRID_SIZE }, () => Array(GRID_SIZE).fill(false)));
142+
setGameOver(false);
143+
setScore(0);
144+
setActiveCell(null);
145+
};
146+
147+
if (!board) {
148+
return (
149+
<GameLayout>
150+
<p className="text-center">Could not generate a valid board. Try again.</p>
151+
<Button onClick={restart}>Retry</Button>
152+
</GameLayout>
153+
);
154+
}
155+
156+
return (
157+
<GameLayout className="!w-full sm:!w-[600px]">
158+
<h1 className="font-semibold text-xl text-center">Border Bingo</h1>
159+
<p className="text-sm text-center opacity-70">
160+
Pick a country that borders both the row and column country
161+
</p>
162+
163+
{/* Grid */}
164+
<div className="overflow-x-auto">
165+
<div
166+
className="grid gap-1 w-full"
167+
style={{
168+
gridTemplateColumns: `80px repeat(${GRID_SIZE}, 1fr)`,
169+
gridTemplateRows: `60px repeat(${GRID_SIZE}, 80px)`,
170+
}}
171+
>
172+
{/* Empty top-left corner */}
173+
<div />
174+
175+
{/* Column headers */}
176+
{board.cols.map((country) => (
177+
<div
178+
key={country.cca3}
179+
className="flex flex-col items-center justify-center text-center p-1"
180+
>
181+
<img
182+
src={country.flags.svg}
183+
alt={country.name.common}
184+
className="w-8 h-5 object-contain"
185+
/>
186+
<span className="text-xs font-medium leading-tight mt-1">
187+
{country.name.common}
188+
</span>
189+
</div>
190+
))}
191+
192+
{/* Rows */}
193+
{board.rows.map((rowCountry, r) => (
194+
<>
195+
{/* Row header */}
196+
<div
197+
key={`rh-${rowCountry.cca3}`}
198+
className="flex flex-col items-center justify-center text-center p-1"
199+
>
200+
<img
201+
src={rowCountry.flags.svg}
202+
alt={rowCountry.name.common}
203+
className="w-8 h-5 object-contain"
204+
/>
205+
<span className="text-xs font-medium leading-tight mt-1">
206+
{rowCountry.name.common}
207+
</span>
208+
</div>
209+
210+
{/* Cells */}
211+
{board.cols.map((_, c) => {
212+
const cell = selected[r][c];
213+
const isActive =
214+
activeCell && activeCell.r === r && activeCell.c === c;
215+
return (
216+
<button
217+
key={`${r}-${c}`}
218+
onClick={() => handleCellClick(r, c)}
219+
disabled={revealed[r][c]}
220+
className={`border rounded flex items-center justify-center text-xs p-1 transition-all cursor-pointer
221+
${isActive ? "ring-2 ring-blue-400 bg-blue-500/20" : ""}
222+
${cell?.correct ? "bg-valid/20 border-valid" : ""}
223+
${cell && !cell.correct ? "bg-invalid/20 border-invalid" : ""}
224+
${!cell && !isActive ? "bg-white/5 hover:bg-white/10 dark:hover:bg-white/10" : ""}
225+
`}
226+
>
227+
{cell && (
228+
<div className="flex flex-col items-center">
229+
<img
230+
src={cell.country.flags.svg}
231+
className="w-6 h-4 object-contain"
232+
alt=""
233+
/>
234+
<span className="leading-tight mt-0.5 truncate max-w-[70px]">
235+
{cell.country.name.common}
236+
</span>
237+
</div>
238+
)}
239+
{!cell && "?"}
240+
</button>
241+
);
242+
})}
243+
</>
244+
))}
245+
</div>
246+
</div>
247+
248+
{/* Country picker */}
249+
{activeCell && !gameOver && (
250+
<div className="flex flex-col gap-2 border-t pt-3">
251+
<input
252+
type="text"
253+
value={search}
254+
onChange={(e) => setSearch(e.target.value)}
255+
placeholder="Search country..."
256+
autoFocus
257+
className="shadow rounded p-2 bg-white dark:bg-dark-mode-light border outline-none focus:ring-2 focus:ring-blue-400"
258+
/>
259+
<div className="max-h-40 overflow-y-auto flex flex-col gap-1">
260+
{filteredPool.map((country) => (
261+
<button
262+
key={country.cca3}
263+
onClick={() => handleSelect(country)}
264+
className="text-left px-3 py-1.5 rounded hover:bg-black/10 dark:hover:bg-white/10 text-sm flex items-center gap-2"
265+
>
266+
<img
267+
src={country.flags.svg}
268+
className="w-5 h-3 object-contain"
269+
alt=""
270+
/>
271+
{country.name.common}
272+
</button>
273+
))}
274+
</div>
275+
</div>
276+
)}
277+
278+
{/* Score / Game Over */}
279+
{gameOver && (
280+
<div className="flex flex-col items-center gap-2 border-t pt-3">
281+
<p className="text-lg font-bold">
282+
{score}/{GRID_SIZE * GRID_SIZE} correct!
283+
</p>
284+
<div className="flex gap-3">
285+
<Button to="/games">Menu</Button>
286+
<Button onClick={restart}>Play again</Button>
287+
</div>
288+
</div>
289+
)}
290+
291+
{!gameOver && !activeCell && (
292+
<p className="text-xs text-center opacity-50">
293+
Tap a cell to pick a country • {score}/{GRID_SIZE * GRID_SIZE}
294+
</p>
295+
)}
296+
</GameLayout>
297+
);
298+
}

src/games/GameDashboard.jsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import {
33
ArrowTrendingUpIcon as TrendingUpIcon,
44
ListBulletIcon as ViewListIcon,
55
LanguageIcon,
6+
TableCellsIcon,
67
} from "@heroicons/react/24/outline";
78
import {
89
FlagIcon as FlagIconSolid,
910
ArrowTrendingUpIcon as TrendingUpIconSolid,
1011
ListBulletIcon as ViewListIconSolid,
1112
LanguageIcon as LanguageIconSolid,
13+
TableCellsIcon as TableCellsIconSolid,
1214
} from "@heroicons/react/24/solid";
1315
import { useContext, useEffect, useMemo, useState } from "react";
1416
import { Link } from "react-router-dom";
@@ -143,6 +145,13 @@ export default function GameDashboard() {
143145
title="Higher or Lower"
144146
description="Compare populations — guess which is bigger"
145147
/>
148+
<GameCard
149+
to="/borderbingo"
150+
icon={TableCellsIcon}
151+
iconSolid={TableCellsIconSolid}
152+
title="Border Bingo"
153+
description="Pick countries that border both the row and column"
154+
/>
146155
</div>
147156
</div>
148157
</div>

0 commit comments

Comments
 (0)