diff --git a/css/styles.css b/css/styles.css
index 010b263..4f941c8 100644
--- a/css/styles.css
+++ b/css/styles.css
@@ -111,8 +111,7 @@ dialog.window button {
background-color: rgba(0, 0, 0, 0.3);
cursor: pointer;
}
-dialog.window iframe {
- border-top: none;
+dialog.window canvas {
border-bottom: solid;
border-left: solid;
border-right: solid;
diff --git a/index.html b/index.html
index 573159c..20a0dc1 100644
--- a/index.html
+++ b/index.html
@@ -6,16 +6,17 @@
+
www.madstuff.net
diff --git a/js/tennis.js b/js/tennis.js
new file mode 100644
index 0000000..58e035a
--- /dev/null
+++ b/js/tennis.js
@@ -0,0 +1,228 @@
+/**
+ * @file Implements a tennis game.
+ *
+ * @licstart The following is the entire license notice for the JavaScript code
+ * in this page.
+ *
+ * Copyright (C) 2026 Madison L.
+ *
+ * The JavaScript code in this page is free software: you can redistribute it
+ * and/or modify it under the terms of the GNU General Public License (GNU GPL)
+ * as published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version. The code is distributed
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
+ *
+ * As additional permission under GNU GPL version 3 section 7, you may
+ * distribute non-source (e.g., minimized or compacted) forms of that code
+ * without the copy of the GNU GPL normally required by section 4, provided you
+ * include this license notice and a URL through which recipients can access the
+ * Corresponding Source.
+ *
+ * @licend The above is the entire license notice for the JavaScript code in
+ * this page.
+ *
+ * Behold! My latest contribution to the site's growing technical debt, this
+ * time with 80% more duct tape and glue. Bask in the constant recomputations of
+ * the same values, repeated logic, and magic numbers galore! I just want to get
+ * this working, I'll fix it later.
+ */
+
+const gameState = {
+ START_MENU: 0,
+ IN_GAME: 1,
+ PAUSED: 2,
+}
+
+function rectsIntersecting(a, b) {
+ return (a.x-(a.w/2) < b.x+(b.w/2)) && (a.x+(a.w/2) > b.x-(b.w/2))
+ && (a.y-(a.w/2) < b.y+(b.w/2)) && (a.y+(a.w/2) > b.y-(b.w/2));
+}
+
+function initGame() {
+ let canvas = document.getElementById("canvas");
+ let gameData = {
+ canvas: canvas,
+ canvasContext: canvas.getContext("2d"),
+ paddleLeft: {
+ rectangle: { x: 10, y: canvas.height/2, w: 10, h: 20 }, // px
+ controller: updatePaddleP,
+ },
+ paddleRight: {
+ rectangle: {
+ x: canvas.width-10, // px
+ y: canvas.height/2, // px
+ w: 10, // px
+ h: 20, // px
+ },
+ controller: updatePaddleCPU,
+ },
+ paddleSpeed: 1, // px/s
+ ball: {
+ rectangle: {
+ x: canvas.width/2,
+ y: canvas.height/2,
+ w: 10,
+ h: 10
+ },
+ direction: { x: -1, y: -1 },
+ speed: 0.5, // px/s
+ increment: 0.01, // px/collision
+ paddleHit: false,
+ },
+ score: { left: 0, right: 0 },
+ gameState: gameState.START_MENU,
+ targetFrameTime: 1/30, // 30 FPS
+ upPressed: false,
+ downPressed: false,
+ };
+ window.addEventListener("keydown", (event) => {
+ if (event.code == "KeyW")
+ gameData.upPressed = true;
+ else if (event.code == "KeyS")
+ gameData.downPressed = true;
+ else if ((event.code == "Escape")
+ && (gameData.gameState != gameState.START_MENU))
+ gameData.gameState = (gameData.gameState == gameState.IN_GAME)
+ ? gameState.PAUSED : gameState.IN_GAME;
+ else if ((event.code == "Enter")
+ && (gameData.gameState == gameState.START_MENU))
+ gameData.gameState = gameState.IN_GAME;
+ });
+ window.addEventListener("keyup", (event) => {
+ if (event.code == "KeyW")
+ gameData.upPressed = false;
+ else if (event.code == "KeyS")
+ gameData.downPressed = false;
+ });
+ return gameData;
+}
+
+function updatePaddleP(paddle, gameData) {
+ if ((gameData.upPressed) && ((paddle.rectangle.y-paddle.rectangle.w) >= 0))
+ paddle.rectangle.y -= gameData.paddleSpeed;
+ if ((gameData.downPressed)
+ && ((paddle.rectangle.y+paddle.rectangle.w) <= gameData.canvas.height))
+ paddle.rectangle.y += gameData.paddleSpeed;
+}
+
+function updatePaddleCPU(paddle, gameData) {
+ if (gameData.ball.rectangle.x < gameData.canvas.width/2)
+ return;
+ if (paddle.rectangle.y < gameData.ball.rectangle.y) {
+ paddle.rectangle.y += gameData.paddleSpeed;
+ }
+ if (paddle.rectangle.y > gameData.ball.rectangle.y) {
+ paddle.rectangle.y -= gameData.paddleSpeed;
+ }
+}
+
+function resetBall(ball) {
+ ball.rectangle.x = canvas.width/2;
+ ball.rectangle.y = canvas.height/2;
+ ball.direction.x *= -1;
+ ball.direction.y *= -1;
+ ball.speed = 0.5;
+}
+
+function updateBall(gameData) {
+ gameData.ball.rectangle.x += gameData.ball.direction.x*gameData.ball.speed;
+ gameData.ball.rectangle.y += gameData.ball.direction.y*gameData.ball.speed;
+ // r/programminghorror:
+ if ((gameData.ball.rectangle.y <= 0)
+ || (gameData.ball.rectangle.y >= gameData.canvas.height)) {
+ gameData.ball.direction.y *= -1;
+ gameData.ball.paddleHit = false;
+ gameData.ball.speed += 0.01;
+ } else if ((rectsIntersecting(gameData.ball.rectangle,
+ gameData.paddleLeft.rectangle)
+ || rectsIntersecting(gameData.ball.rectangle,
+ gameData.paddleRight.rectangle))
+ && !gameData.ball.paddleHit) {
+ /*
+ * HACK: Ball gets stuck in paddle sometimes. This kinda fixes it, but
+ * it results in occasional weird behaviour where the ball is
+ * reflected when it probably shouldn't be. I think that's the
+ * least of the problems with this function though.
+ */
+ gameData.ball.paddleHit = true;
+ gameData.ball.direction.x *= -1;
+ gameData.ball.speed += 0.01;
+ } else if (gameData.ball.rectangle.x <= 0) {
+ ++gameData.score.right;
+ resetBall(gameData.ball);
+ } else if (gameData.ball.rectangle.x >= gameData.canvas.width) {
+ ++gameData.score.left;
+ resetBall(gameData.ball);
+ }
+}
+
+function drawPaddle(paddle, gameData) {
+ gameData.canvasContext.fillStyle = "white";
+ const halfWidth = paddle.rectangle.w/2;
+ const halfHeight = paddle.rectangle.h/2;
+ gameData.canvasContext.fillRect(paddle.rectangle.x-halfWidth,
+ paddle.rectangle.y-halfHeight,
+ paddle.rectangle.w,
+ paddle.rectangle.h);
+}
+
+function drawBall(gameData) {
+ gameData.canvasContext.fillStyle = "white";
+ const halfWidth = gameData.ball.rectangle.w/2;
+ gameData.canvasContext.fillRect(gameData.ball.rectangle.x-halfWidth,
+ gameData.ball.rectangle.y-halfWidth,
+ gameData.ball.rectangle.w,
+ gameData.ball.rectangle.h);
+}
+
+function drawUI(gameData) {
+ gameData.canvasContext.fillStyle = "white";
+ gameData.canvasContext.font = "20px 'Courier New'";
+ if (gameData.gameState == gameState.START_MENU) {
+ gameData.canvasContext.fillText("Press Enter to Play", 45, 90, 250);
+ } else if (gameData.gameState == gameState.PAUSED) {
+ gameData.canvasContext.fillText("PAUSED", 120, 100, 100);
+ } else {
+ gameData.canvasContext.fillRect(gameData.canvas.width/2, 0, 2,
+ gameData.canvas.height);
+ gameData.canvasContext.fillText(gameData.score.left,
+ (gameData.canvas.width/2)-(20*2)-5,
+ 20,
+ 80);
+ gameData.canvasContext.fillText(gameData.score.right,
+ (gameData.canvas.width/2)+(20*2),
+ 20,
+ 80);
+
+ }
+}
+
+function updateGame(gameData) {
+ gameData.paddleLeft.controller(gameData.paddleLeft, gameData);
+ gameData.paddleRight.controller(gameData.paddleRight, gameData);
+ updateBall(gameData);
+}
+
+function drawGame(gameData) {
+ gameData.canvasContext.fillStyle = "black";
+ gameData.canvasContext.fillRect(0, 0, gameData.canvas.width,
+ gameData.canvas.height);
+ if (gameData.gameState == gameState.IN_GAME) {
+ drawPaddle(gameData.paddleLeft, gameData);
+ drawPaddle(gameData.paddleRight, gameData);
+ drawBall(gameData);
+ }
+ drawUI(gameData);
+}
+
+function runGame() {
+ let gameData = initGame();
+ setInterval(function() {
+ if (gameData.gameState == gameState.IN_GAME)
+ updateGame(gameData);
+ drawGame(gameData);
+ }, gameData.targetFrameTime);
+}
+
+runGame();