Skip to content

Commit 7671d6c

Browse files
committed
Added Snake Game!
Added the ability to play snake right on the main site! 🐍🐍🐍
1 parent ce8a167 commit 7671d6c

File tree

2 files changed

+277
-34
lines changed

2 files changed

+277
-34
lines changed

src/App.jsx

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import React from "react";
12
import { Button } from "react-bootstrap";
23
import ThemeToggle from "./components/ThemeToggle.jsx";
4+
import SnakeCard from "./components/SnakeCard.jsx";
35
import memoji from "./assets/memoji.png";
46

5-
67
export default function App() {
78
return (
89
<>
@@ -12,32 +13,31 @@ export default function App() {
1213

1314
{/* LEFT: hero spans 2/3 on md+ */}
1415
<div className="col-12 col-md-8">
15-
<div className="tile tile--hero tile--cta h-100 d-flex flex-column flex-md-row align-items-center justify-content-between text-md-start text-center">
16-
<div className="flex-grow-1 pe-md-4">
17-
<h1 className="hero-title mb-2">
18-
Hi! I'm <span style={{ color: "#22c55e" }}>Matt</span> - I'm a Cybersecurity Engineer!
19-
</h1>
20-
<p className="hero-blurb mb-3">
21-
I build <strong>people-first</strong> ingestion pipelines & automations in TDLM—
22-
making internal & external risks visible early and actionable for stakeholders.
23-
</p>
24-
<div className="d-flex flex-wrap justify-content-center justify-content-md-start gap-2">
25-
<Button href="#about" size="sm">About</Button>
26-
<Button variant="outline-primary" href="https://github.com/Paebak" size="sm">GitHub</Button>
27-
<Button variant="outline-secondary" href="/matt-downs-resume.pdf" size="sm">Resume</Button>
28-
</div>
29-
</div>
30-
31-
<div className="mt-4 mt-md-0 text-center">
32-
<img
33-
src={memoji}
34-
alt="Matt Memoji"
35-
className="hero-img"
36-
/>
37-
</div>
38-
</div>
39-
</div>
16+
<div className="tile tile--hero tile--cta h-100 d-flex flex-column flex-md-row align-items-center justify-content-between text-md-start text-center">
17+
<div className="flex-grow-1 pe-md-4">
18+
<h1 className="hero-title mb-2">
19+
Hi! I'm <span style={{ color: "#22c55e" }}>Matt</span> - I'm a Cybersecurity Engineer!
20+
</h1>
21+
<p className="hero-blurb mb-3">
22+
I build <strong>people-first</strong> ingestion pipelines & automations in TDLM—
23+
making internal & external risks visible early and actionable for stakeholders.
24+
</p>
25+
<div className="d-flex flex-wrap justify-content-center justify-content-md-start gap-2">
26+
<Button href="#about" size="sm">About</Button>
27+
<Button variant="outline-primary" href="https://github.com/Paebak" size="sm">GitHub</Button>
28+
<Button variant="outline-secondary" href="/matt-downs-resume.pdf" size="sm">Resume</Button>
29+
</div>
30+
</div>
4031

32+
<div className="mt-4 mt-md-0 text-center">
33+
<img
34+
src={memoji}
35+
alt="Matt Memoji"
36+
className="hero-img"
37+
/>
38+
</div>
39+
</div>
40+
</div>
4141

4242
{/* RIGHT: two stacked tiles */}
4343
<div className="col-12 col-md-4 d-flex flex-column gap-3">
@@ -104,22 +104,19 @@ export default function App() {
104104
</div>
105105
</div>
106106

107+
{/* SNAKE GAME TILE */}
107108
<div className="col-12 col-md-6">
108-
<div id="contact" className="tile tile--cta h-100">
109-
<h5 className="mb-1">Contact 📝</h5>
110-
<div className="text-secondary small mb-2">Email / GitHub — let’s collaborate.</div>
111-
<div className="d-flex gap-2">
112-
<Button href="mailto:[email protected]" size="sm">Email</Button>
113-
<Button href="https://github.com/Paebak" variant="outline-secondary" size="sm">GitHub</Button>
114-
</div>
109+
{/* SnakeCard renders its own Bootstrap Card; we wrap it in your tile container for consistent spacing */}
110+
<div className="tile tile--cta h-100 p-0">
111+
<SnakeCard />
115112
</div>
116113
</div>
117114

118115
</div>
119116
</div>
120117
</section>
121118
<footer className="text-center py-3 text-secondary small">
122-
© {new Date().getFullYear()} Matt Downs. All rights reserved.
119+
© {new Date().getFullYear()} Matt Downs. All rights reserved.
123120
</footer>
124121
</>
125122
);

src/components/SnakeCard.jsx

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
// src/components/SnakeCard.jsx
2+
import React, { useEffect, useRef, useState, useCallback } from "react";
3+
import { Card, Button, ButtonGroup, Badge } from "react-bootstrap";
4+
5+
/**
6+
* <SnakeCard /> — drop this inside any React-Bootstrap layout.
7+
* Arrow keys / WASD to move. Space = pause. R = reset.
8+
*/
9+
export default function SnakeCard() {
10+
// Tunables
11+
const COLS = 20;
12+
const ROWS = 20;
13+
const TILE = 18;
14+
const STEP_MS_INITIAL = 120;
15+
16+
// Refs & state
17+
const canvasRef = useRef(null);
18+
const loopRef = useRef(null);
19+
const lastTickRef = useRef(0);
20+
const stepMsRef = useRef(STEP_MS_INITIAL);
21+
const dirRef = useRef({ x: 1, y: 0 });
22+
const pendingDirRef = useRef({ x: 1, y: 0 });
23+
const [running, setRunning] = useState(false);
24+
const [score, setScore] = useState(0);
25+
const [high, setHigh] = useState(() => Number(localStorage.getItem("snake.high") || 0));
26+
const [speedLabel, setSpeedLabel] = useState("Normal");
27+
28+
const snakeRef = useRef([]);
29+
const foodRef = useRef({ x: 5, y: 5 });
30+
31+
const randomEmptyCell = useCallback(() => {
32+
const taken = new Set(snakeRef.current.map(p => `${p.x},${p.y}`));
33+
let x, y;
34+
do {
35+
x = Math.floor(Math.random() * COLS);
36+
y = Math.floor(Math.random() * ROWS);
37+
} while (taken.has(`${x},${y}`));
38+
return { x, y };
39+
}, [COLS, ROWS]);
40+
41+
const resetGame = useCallback(() => {
42+
const cx = Math.floor(COLS / 2);
43+
const cy = Math.floor(ROWS / 2);
44+
snakeRef.current = [
45+
{ x: cx - 1, y: cy },
46+
{ x: cx - 2, y: cy },
47+
{ x: cx - 3, y: cy },
48+
];
49+
dirRef.current = { x: 1, y: 0 };
50+
pendingDirRef.current = { x: 1, y: 0 };
51+
foodRef.current = randomEmptyCell();
52+
setScore(0);
53+
}, [COLS, ROWS, randomEmptyCell]);
54+
55+
const draw = useCallback(() => {
56+
const canvas = canvasRef.current;
57+
if (!canvas) return;
58+
const dpr = window.devicePixelRatio || 1;
59+
const logicalW = COLS * TILE;
60+
const logicalH = ROWS * TILE;
61+
62+
// Responsive backing store
63+
const cssWidth = canvas.clientWidth;
64+
const scale = cssWidth / logicalW;
65+
canvas.width = Math.floor(logicalW * dpr * scale);
66+
canvas.height = Math.floor(logicalH * dpr * scale);
67+
68+
const ctx = canvas.getContext("2d");
69+
ctx.setTransform(dpr * scale, 0, 0, dpr * scale, 0, 0);
70+
71+
// Background
72+
ctx.fillStyle =
73+
getComputedStyle(document.documentElement).getPropertyValue("--bs-body-bg") || "#0b0d0f";
74+
ctx.fillRect(0, 0, logicalW, logicalH);
75+
76+
// Grid
77+
ctx.strokeStyle = "rgba(255,255,255,0.05)";
78+
ctx.lineWidth = 1;
79+
for (let i = 1; i < COLS; i++) {
80+
ctx.beginPath(); ctx.moveTo(i * TILE, 0); ctx.lineTo(i * TILE, logicalH); ctx.stroke();
81+
}
82+
for (let j = 1; j < ROWS; j++) {
83+
ctx.beginPath(); ctx.moveTo(0, j * TILE); ctx.lineTo(logicalW, j * TILE); ctx.stroke();
84+
}
85+
86+
// Food
87+
const f = foodRef.current;
88+
ctx.fillStyle =
89+
getComputedStyle(document.documentElement).getPropertyValue("--bs-success") || "#4ade80";
90+
ctx.fillRect(f.x * TILE + 2, f.y * TILE + 2, TILE - 4, TILE - 4);
91+
92+
// Snake
93+
const snake = snakeRef.current;
94+
ctx.fillStyle =
95+
getComputedStyle(document.documentElement).getPropertyValue("--bs-primary") || "#38bdf8";
96+
snake.forEach((p, idx) => {
97+
const r = idx === 0 ? 3 : 2;
98+
ctx.beginPath();
99+
// roundRect is widely supported in modern browsers
100+
ctx.roundRect(p.x * TILE + 1, p.y * TILE + 1, TILE - 2, TILE - 2, r);
101+
ctx.fill();
102+
});
103+
}, [COLS, ROWS, TILE]);
104+
105+
const gameOver = useCallback(() => {
106+
setRunning(false);
107+
const canvas = canvasRef.current;
108+
if (!canvas) return;
109+
const ctx = canvas.getContext("2d");
110+
ctx.save();
111+
ctx.globalAlpha = 0.25;
112+
ctx.fillStyle = "#dc3545";
113+
ctx.fillRect(0, 0, canvas.width, canvas.height);
114+
ctx.restore();
115+
}, []);
116+
117+
const step = useCallback((t) => {
118+
if (!running) return;
119+
if (!lastTickRef.current) lastTickRef.current = t;
120+
const elapsed = t - lastTickRef.current;
121+
if (elapsed < stepMsRef.current) {
122+
loopRef.current = requestAnimationFrame(step);
123+
return;
124+
}
125+
lastTickRef.current = t;
126+
127+
const cur = dirRef.current; const next = pendingDirRef.current;
128+
if (next.x !== -cur.x || next.y !== -cur.y) dirRef.current = next;
129+
130+
const snake = snakeRef.current.slice();
131+
const head = { ...snake[0] };
132+
head.x += dirRef.current.x;
133+
head.y += dirRef.current.y;
134+
135+
// Walls
136+
if (head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS) return gameOver();
137+
// Self
138+
if (snake.some(p => p.x === head.x && p.y === head.y)) return gameOver();
139+
140+
snake.unshift(head);
141+
if (head.x === foodRef.current.x && head.y === foodRef.current.y) {
142+
setScore(s => {
143+
const ns = s + 1;
144+
if (ns > high) {
145+
localStorage.setItem("snake.high", String(ns));
146+
setHigh(ns);
147+
}
148+
return ns;
149+
});
150+
foodRef.current = randomEmptyCell();
151+
} else {
152+
snake.pop();
153+
}
154+
155+
snakeRef.current = snake;
156+
draw();
157+
loopRef.current = requestAnimationFrame(step);
158+
}, [COLS, ROWS, draw, gameOver, high, randomEmptyCell, running]);
159+
160+
// Keyboard
161+
useEffect(() => {
162+
const onKey = (e) => {
163+
const k = e.key.toLowerCase();
164+
const map = {
165+
arrowup: { x: 0, y: -1 }, w: { x: 0, y: -1 },
166+
arrowdown: { x: 0, y: 1 }, s: { x: 0, y: 1 },
167+
arrowleft: { x: -1, y: 0 }, a: { x: -1, y: 0 },
168+
arrowright: { x: 1, y: 0 }, d: { x: 1, y: 0 },
169+
};
170+
if (map[k]) {
171+
pendingDirRef.current = map[k];
172+
e.preventDefault();
173+
} else if (k === " ") {
174+
setRunning(prev => !prev);
175+
e.preventDefault();
176+
} else if (k === "r") {
177+
resetGame(); draw();
178+
}
179+
};
180+
window.addEventListener("keydown", onKey, { passive: false });
181+
return () => window.removeEventListener("keydown", onKey);
182+
}, [draw, resetGame]);
183+
184+
// Loop start/stop
185+
useEffect(() => {
186+
if (running) {
187+
lastTickRef.current = 0;
188+
loopRef.current = requestAnimationFrame(step);
189+
} else if (loopRef.current) {
190+
cancelAnimationFrame(loopRef.current);
191+
}
192+
return () => loopRef.current && cancelAnimationFrame(loopRef.current);
193+
}, [running, step]);
194+
195+
// Init + responsive redraw
196+
useEffect(() => {
197+
resetGame();
198+
const ro = new ResizeObserver(() => draw());
199+
if (canvasRef.current) ro.observe(canvasRef.current);
200+
draw();
201+
return () => ro.disconnect();
202+
}, [draw, resetGame]);
203+
204+
const setSpeed = (label, ms) => {
205+
setSpeedLabel(label);
206+
stepMsRef.current = ms;
207+
};
208+
209+
return (
210+
<Card className="h-100 shadow-sm">
211+
<Card.Header className="d-flex justify-content-between align-items-center">
212+
<span><strong>Play Snake! 🐍</strong> <Badge bg="secondary" className="ms-2">{COLS}×{ROWS}</Badge></span>
213+
<div className="d-flex align-items-center gap-2">
214+
<Badge bg="info" title="Current score">Score: {score}</Badge>
215+
<Badge bg="success" title="Best on this browser">High Score!: {high}</Badge>
216+
</div>
217+
</Card.Header>
218+
<Card.Body className="d-flex flex-column">
219+
<div className="ratio ratio-1x1 border rounded" style={{ overflow: "hidden" }}>
220+
<canvas ref={canvasRef} style={{ width: "100%", height: "100%", display: "block" }} />
221+
</div>
222+
223+
<div className="d-flex justify-content-between align-items-center mt-3 flex-wrap gap-2">
224+
<ButtonGroup>
225+
<Button variant={running ? "warning" : "primary"} onClick={() => setRunning(p => !p)}>
226+
{running ? "Pause (Space)" : "Start (Space)"}
227+
</Button>
228+
<Button variant="outline-secondary" onClick={() => { resetGame(); draw(); }}>
229+
Reset (R)
230+
</Button>
231+
</ButtonGroup>
232+
233+
<ButtonGroup>
234+
<Button variant={speedLabel === "Slow" ? "success" : "outline-success"} onClick={() => setSpeed("Slow", 160)}>Slow</Button>
235+
<Button variant={speedLabel === "Normal" ? "success" : "outline-success"} onClick={() => setSpeed("Normal", 120)}>Normal</Button>
236+
<Button variant={speedLabel === "Fast" ? "success" : "outline-success"} onClick={() => setSpeed("Fast", 80)}>Fast</Button>
237+
</ButtonGroup>
238+
</div>
239+
240+
<small className="text-muted mt-2">
241+
Controls: Arrow keys / WASD. Space to pause/resume. R to restart.
242+
</small>
243+
</Card.Body>
244+
</Card>
245+
);
246+
}

0 commit comments

Comments
 (0)