For my midterm project, I decided to create my own version of the classic Whack-A-Mole game. The goal was to design an interactive experience where players could test their reflexes by clicking on moles as they randomly appeared on the screen. It was inspired by both traditional arcade versions and mobile games, where I wanted to reach a balance between simple navigation and entertainment.
One of the aspects I’m particularly proud of is the way I structured the game logic. I tried to keep my code modular by organizing key components into functions. This not only made the development process more efficient but also allowed me to tweak game parameters easily. Another feature I really enjoyed implementing was the randomization of mole appearances that ensures that no two games feel exactly the same. The timing and positioning algorithms took some hard work, but I’m happy with how smooth the flow of the game turned out.
How it works: Moles pop up from different holes at random intervals, and the player must click on them to score points. Each mole stays visible for a short duration before disappearing, which requires quick reactions from the player. As the game progresses, the frequency of mole appearances increases, adding a greater challenge over time. To add an element of risk, I implemented a bomb mechanic, meaning that if a player accidentally clicks on a bomb instead of a mole, the game ends immediately. This twist keeps players alert and encourages more precise clicking. The game runs in a continuous loop with constant updates on the screen that track the player’s score. Each successful mole hit adds points, while avoiding bombs becomes crucial for “survival”.
Challenges: One of the biggest issues was making sure the game felt responsive without being overwhelming. Initially, the moles appeared and disappeared too quickly which made it nearly impossible to hit them in time. I had to experiment with different timing settings to find the right balance. Another tricky aspect was collision detection, where I had to make sure that clicks were registered correctly when a mole is hit and not on empty spots or bombs. Debugging this issue took some time, but through testing and refining my hitbox logic, I was able to get it working.
Implementation: The game is built with JavaScript and p5.js, which handle the graphics and interactions. I used free images and sound effects from online stocks to make the game more engaging. The Game
class manages the core mechanics which includes the following:
- Hole and Object Management – moles and bombs are placed randomly to keep the game unpredictable
- Scoring System – players earn points for hitting moles, while bombs immediately end the game
- Difficulty Scaling – as the game progresses, moles appear and disappear faster, making it more challenging
- Sound Effects and Graphics – Background music, whacking sounds, and animations add to an immersive experienceCode snippets:>>Managing moles and bombs
if (mouseIsPressed) {if (dist(mouseX, mouseY, hole.x, hole.y) < hole.d) {mouseIsPressed = false;punched = true;sounds.whak.play();setTimeout(() => {punched = false;}, 200);if (hole.type == "mole") {hole.type = "hole";game.score += 1;game.timer += 30;} else {sounds.bomb.play();gameOver();}}}if (mouseIsPressed) { if (dist(mouseX, mouseY, hole.x, hole.y) < hole.d) { mouseIsPressed = false; punched = true; sounds.whak.play(); setTimeout(() => { punched = false; }, 200); if (hole.type == "mole") { hole.type = "hole"; game.score += 1; game.timer += 30; } else { sounds.bomb.play(); gameOver(); } } }
if (mouseIsPressed) { if (dist(mouseX, mouseY, hole.x, hole.y) < hole.d) { mouseIsPressed = false; punched = true; sounds.whak.play(); setTimeout(() => { punched = false; }, 200); if (hole.type == "mole") { hole.type = "hole"; game.score += 1; game.timer += 30; } else { sounds.bomb.play(); gameOver(); } } }
>>Registering the mouse clicks
if (frameCount % (game.difficulty - game.score) == 0) {let hole = random(game.holes);if (hole.type == "mole") {hole.type = "hole";} else {hole.type = "mole";}if (random(1) < 0.1) {hole.type = "bomb";setTimeout(() => {hole.type = "hole";}, 1000);}}if (frameCount % (game.difficulty - game.score) == 0) { let hole = random(game.holes); if (hole.type == "mole") { hole.type = "hole"; } else { hole.type = "mole"; } if (random(1) < 0.1) { hole.type = "bomb"; setTimeout(() => { hole.type = "hole"; }, 1000); } }if (frameCount % (game.difficulty - game.score) == 0) { let hole = random(game.holes); if (hole.type == "mole") { hole.type = "hole"; } else { hole.type = "mole"; } if (random(1) < 0.1) { hole.type = "bomb"; setTimeout(() => { hole.type = "hole"; }, 1000); } }
>>Game Over logic
function gameOver() {sounds.gameover.play();setTimeout(() => {sounds.back.stop();image(imgs.blast.img, mouseX, mouseY, 250, 250);background(20, 100);textSize(64);text("Game Over", width / 2, height / 2);textSize(16);text("click anywhere to restart!", width / 2, height - 50);textSize(46);text(game.score, width / 2, 35);state = "gameOver";}, 100);noLoop();}function gameOver() { sounds.gameover.play(); setTimeout(() => { sounds.back.stop(); image(imgs.blast.img, mouseX, mouseY, 250, 250); background(20, 100); textSize(64); text("Game Over", width / 2, height / 2); textSize(16); text("click anywhere to restart!", width / 2, height - 50); textSize(46); text(game.score, width / 2, 35); state = "gameOver"; }, 100); noLoop(); }function gameOver() { sounds.gameover.play(); setTimeout(() => { sounds.back.stop(); image(imgs.blast.img, mouseX, mouseY, 250, 250); background(20, 100); textSize(64); text("Game Over", width / 2, height / 2); textSize(16); text("click anywhere to restart!", width / 2, height - 50); textSize(46); text(game.score, width / 2, 35); state = "gameOver"; }, 100); noLoop(); }
Improvements: Looking forward, there are definitely some areas that I’d like to improve. One feature I would like to add is a progressive difficulty system, where players are challenged at different levels of difficulty. Right now, the game is fun but could benefit from more depth. On top of that, I’d like to upgrade the user interface by adding a “start” and “home” screen, score tracker, and possibly a leaderboard.
let imgs = {};let sounds = {};let punched = false;let state = "start";function preload() {backImg = loadImage("assets/back.png");font = loadFont("assets/Sigmar-Regular.ttf");imgs.blast = { img: loadImage("assets/blast.png"), xoff: 0, yoff: 0 };imgs.bomb = { img: loadImage("assets/bomb.png"), xoff: 0, yoff: 0 };imgs.hammer = { img: loadImage("assets/hammer.png"), xoff: 0, yoff: 0 };imgs.hole = { img: loadImage("assets/hole.png"), xoff: 0, yoff: 30 };imgs.mole = { img: loadImage("assets/mole.png"), xoff: 0, yoff: 0 };sounds.bomb = loadSound("sounds/Bomb hit.mp3");sounds.back = loadSound("sounds/Game main theme.mp3");sounds.gameover = loadSound("sounds/game-over.mp3");sounds.whak = loadSound("sounds/Whacking a mole.mp3");}function setup() {createCanvas(600, 600);imageMode(CENTER);textFont(font);game = new Game();textAlign(CENTER, CENTER);}function draw() {image(backImg, width / 2, height / 2, width, height);switch (state) {case "start":sounds.back.stop();textSize(68);fill(255);text("WhACK!\na MOLE!", width / 2, height / 2 - 120);textSize(16);text("press anywhere to start!", width / 2, height - 30);textSize(26);let img = [imgs.hole, imgs.mole, imgs.bomb];image(img[floor(frameCount / 60) % 3].img,width / 2 + img[floor(frameCount / 60) % 3].xoff,height / 2 + 150 + img[floor(frameCount / 60) % 3].yoff);if (mouseIsPressed) {mouseIsPressed = false;sounds.whak.play();state = "game";}break;case "game":game.show();if (!sounds.back.isPlaying()) sounds.back.play();break;}if (mouseX != 0 && mouseY != 0) {push();translate(mouseX, mouseY + 10);if (punched) {rotate(-PI / 2);}scale(map(game.holes.length, 4, 20, 1, 0.25));image(imgs.hammer.img, 0, 0, 150, 150);pop();}}function mousePressed() {if (state == "gameOver") {state = "start";game = new Game();mouseIsPressed = false;loop();}}function gameOver() {sounds.gameover.play();setTimeout(() => {sounds.back.stop();image(imgs.blast.img, mouseX, mouseY, 250, 250);background(20, 100);textSize(64);text("Game Over", width / 2, height / 2);textSize(16);text("click anywhere to restart!", width / 2, height - 50);textSize(46);text(game.score, width / 2, 35);state = "gameOver";}, 100);noLoop();}class Game {constructor() {this.x = 10;this.y = height / 2 - 80;this.w = width - 20;this.h = height / 2 + 70;this.holesNum = 4;this.holes = [];this.difficulty = 60;this.score = 0;this.timer = 4800;}show() {//timerif (this.timer > 4800) this.timer = 4800;this.timer -= 1.5;fill(20, 100);rect(10, 5, width - 20, 10);fill(255);rect(10, 5, map(this.timer, 0, 4800, 0, width - 20), 10);if (this.timer < 0) {mouseX = width / 2;mouseY = height / 2;gameOver();}//scorefill(255);textSize(46);if (punched) textSize(54);text(this.score, width / 2, 35);if (this.holesNum != this.holes.length) {this.holes = this.findHolePositions(1);}for (let i = 0; i < this.holes.length; i++) {push();translate(this.holes[i].x, this.holes[i].y);scale(this.holes[i].d / 250);let img;switch (this.holes[i].type) {case "hole":img = imgs.hole;//nothingbreak;case "mole":img = imgs.mole;break;case "bomb":img = imgs.bomb;break;}if (this.holes[i].type == "mole" || this.holes[i].type == "bomb") {//check mouse click on moleif (mouseIsPressed) {if (dist(mouseX, mouseY, this.holes[i].x, this.holes[i].y) <this.holes[i].d) {mouseIsPressed = false;punched = true;sounds.whak.play();setTimeout(() => {punched = false;}, 200);if (this.holes[i].type == "mole") {this.holes[i].type = "hole";this.score += 1;this.timer += 30;} else {sounds.bomb.play();gameOver();}}}}image(img.img, img.xoff, img.yoff);pop();}if (this.difficulty - this.score < 20) {this.difficulty += 30;this.holesNum += 1;}if (frameCount % (this.difficulty - this.score) == 0) {let hole = random(this.holes);if (hole.type == "mole") {hole.type = "hole";} else {hole.type = "mole";}if (random(1) < 0.1) {hole.type = "bomb";setTimeout(() => {hole.type = "hole";}, 1000);}}}findHolePositions(n, d = 200) {let arr = [];for (let i = 0; i < this.holesNum; i++) {let x = random(this.x + d / 2, this.x + this.w - d / 2);let y = random(this.y + d / 2, this.y + this.h - d / 2);arr.push({ x: x, y: y, d: d, type: "hole" });}//no hole should overlapfor (let i = 0; i < arr.length; i++) {for (let j = 0; j < arr.length; j++) {if (i != j) {let d_ = dist(arr[i].x, arr[i].y, arr[j].x, arr[j].y);if (d_ < d) {n += 1;if (n > 50) {n = 0;d *= 0.9;return this.findHolePositions(n, d);}return this.findHolePositions(n, d);}}}}return arr;}}let imgs = {}; let sounds = {}; let punched = false; let state = "start"; function preload() { backImg = loadImage("assets/back.png"); font = loadFont("assets/Sigmar-Regular.ttf"); imgs.blast = { img: loadImage("assets/blast.png"), xoff: 0, yoff: 0 }; imgs.bomb = { img: loadImage("assets/bomb.png"), xoff: 0, yoff: 0 }; imgs.hammer = { img: loadImage("assets/hammer.png"), xoff: 0, yoff: 0 }; imgs.hole = { img: loadImage("assets/hole.png"), xoff: 0, yoff: 30 }; imgs.mole = { img: loadImage("assets/mole.png"), xoff: 0, yoff: 0 }; sounds.bomb = loadSound("sounds/Bomb hit.mp3"); sounds.back = loadSound("sounds/Game main theme.mp3"); sounds.gameover = loadSound("sounds/game-over.mp3"); sounds.whak = loadSound("sounds/Whacking a mole.mp3"); } function setup() { createCanvas(600, 600); imageMode(CENTER); textFont(font); game = new Game(); textAlign(CENTER, CENTER); } function draw() { image(backImg, width / 2, height / 2, width, height); switch (state) { case "start": sounds.back.stop(); textSize(68); fill(255); text("WhACK!\na MOLE!", width / 2, height / 2 - 120); textSize(16); text("press anywhere to start!", width / 2, height - 30); textSize(26); let img = [imgs.hole, imgs.mole, imgs.bomb]; image( img[floor(frameCount / 60) % 3].img, width / 2 + img[floor(frameCount / 60) % 3].xoff, height / 2 + 150 + img[floor(frameCount / 60) % 3].yoff ); if (mouseIsPressed) { mouseIsPressed = false; sounds.whak.play(); state = "game"; } break; case "game": game.show(); if (!sounds.back.isPlaying()) sounds.back.play(); break; } if (mouseX != 0 && mouseY != 0) { push(); translate(mouseX, mouseY + 10); if (punched) { rotate(-PI / 2); } scale(map(game.holes.length, 4, 20, 1, 0.25)); image(imgs.hammer.img, 0, 0, 150, 150); pop(); } } function mousePressed() { if (state == "gameOver") { state = "start"; game = new Game(); mouseIsPressed = false; loop(); } } function gameOver() { sounds.gameover.play(); setTimeout(() => { sounds.back.stop(); image(imgs.blast.img, mouseX, mouseY, 250, 250); background(20, 100); textSize(64); text("Game Over", width / 2, height / 2); textSize(16); text("click anywhere to restart!", width / 2, height - 50); textSize(46); text(game.score, width / 2, 35); state = "gameOver"; }, 100); noLoop(); } class Game { constructor() { this.x = 10; this.y = height / 2 - 80; this.w = width - 20; this.h = height / 2 + 70; this.holesNum = 4; this.holes = []; this.difficulty = 60; this.score = 0; this.timer = 4800; } show() { //timer if (this.timer > 4800) this.timer = 4800; this.timer -= 1.5; fill(20, 100); rect(10, 5, width - 20, 10); fill(255); rect(10, 5, map(this.timer, 0, 4800, 0, width - 20), 10); if (this.timer < 0) { mouseX = width / 2; mouseY = height / 2; gameOver(); } //score fill(255); textSize(46); if (punched) textSize(54); text(this.score, width / 2, 35); if (this.holesNum != this.holes.length) { this.holes = this.findHolePositions(1); } for (let i = 0; i < this.holes.length; i++) { push(); translate(this.holes[i].x, this.holes[i].y); scale(this.holes[i].d / 250); let img; switch (this.holes[i].type) { case "hole": img = imgs.hole; //nothing break; case "mole": img = imgs.mole; break; case "bomb": img = imgs.bomb; break; } if (this.holes[i].type == "mole" || this.holes[i].type == "bomb") { //check mouse click on mole if (mouseIsPressed) { if ( dist(mouseX, mouseY, this.holes[i].x, this.holes[i].y) < this.holes[i].d ) { mouseIsPressed = false; punched = true; sounds.whak.play(); setTimeout(() => { punched = false; }, 200); if (this.holes[i].type == "mole") { this.holes[i].type = "hole"; this.score += 1; this.timer += 30; } else { sounds.bomb.play(); gameOver(); } } } } image(img.img, img.xoff, img.yoff); pop(); } if (this.difficulty - this.score < 20) { this.difficulty += 30; this.holesNum += 1; } if (frameCount % (this.difficulty - this.score) == 0) { let hole = random(this.holes); if (hole.type == "mole") { hole.type = "hole"; } else { hole.type = "mole"; } if (random(1) < 0.1) { hole.type = "bomb"; setTimeout(() => { hole.type = "hole"; }, 1000); } } } findHolePositions(n, d = 200) { let arr = []; for (let i = 0; i < this.holesNum; i++) { let x = random(this.x + d / 2, this.x + this.w - d / 2); let y = random(this.y + d / 2, this.y + this.h - d / 2); arr.push({ x: x, y: y, d: d, type: "hole" }); } //no hole should overlap for (let i = 0; i < arr.length; i++) { for (let j = 0; j < arr.length; j++) { if (i != j) { let d_ = dist(arr[i].x, arr[i].y, arr[j].x, arr[j].y); if (d_ < d) { n += 1; if (n > 50) { n = 0; d *= 0.9; return this.findHolePositions(n, d); } return this.findHolePositions(n, d); } } } } return arr; } }let imgs = {}; let sounds = {}; let punched = false; let state = "start"; function preload() { backImg = loadImage("assets/back.png"); font = loadFont("assets/Sigmar-Regular.ttf"); imgs.blast = { img: loadImage("assets/blast.png"), xoff: 0, yoff: 0 }; imgs.bomb = { img: loadImage("assets/bomb.png"), xoff: 0, yoff: 0 }; imgs.hammer = { img: loadImage("assets/hammer.png"), xoff: 0, yoff: 0 }; imgs.hole = { img: loadImage("assets/hole.png"), xoff: 0, yoff: 30 }; imgs.mole = { img: loadImage("assets/mole.png"), xoff: 0, yoff: 0 }; sounds.bomb = loadSound("sounds/Bomb hit.mp3"); sounds.back = loadSound("sounds/Game main theme.mp3"); sounds.gameover = loadSound("sounds/game-over.mp3"); sounds.whak = loadSound("sounds/Whacking a mole.mp3"); } function setup() { createCanvas(600, 600); imageMode(CENTER); textFont(font); game = new Game(); textAlign(CENTER, CENTER); } function draw() { image(backImg, width / 2, height / 2, width, height); switch (state) { case "start": sounds.back.stop(); textSize(68); fill(255); text("WhACK!\na MOLE!", width / 2, height / 2 - 120); textSize(16); text("press anywhere to start!", width / 2, height - 30); textSize(26); let img = [imgs.hole, imgs.mole, imgs.bomb]; image( img[floor(frameCount / 60) % 3].img, width / 2 + img[floor(frameCount / 60) % 3].xoff, height / 2 + 150 + img[floor(frameCount / 60) % 3].yoff ); if (mouseIsPressed) { mouseIsPressed = false; sounds.whak.play(); state = "game"; } break; case "game": game.show(); if (!sounds.back.isPlaying()) sounds.back.play(); break; } if (mouseX != 0 && mouseY != 0) { push(); translate(mouseX, mouseY + 10); if (punched) { rotate(-PI / 2); } scale(map(game.holes.length, 4, 20, 1, 0.25)); image(imgs.hammer.img, 0, 0, 150, 150); pop(); } } function mousePressed() { if (state == "gameOver") { state = "start"; game = new Game(); mouseIsPressed = false; loop(); } } function gameOver() { sounds.gameover.play(); setTimeout(() => { sounds.back.stop(); image(imgs.blast.img, mouseX, mouseY, 250, 250); background(20, 100); textSize(64); text("Game Over", width / 2, height / 2); textSize(16); text("click anywhere to restart!", width / 2, height - 50); textSize(46); text(game.score, width / 2, 35); state = "gameOver"; }, 100); noLoop(); } class Game { constructor() { this.x = 10; this.y = height / 2 - 80; this.w = width - 20; this.h = height / 2 + 70; this.holesNum = 4; this.holes = []; this.difficulty = 60; this.score = 0; this.timer = 4800; } show() { //timer if (this.timer > 4800) this.timer = 4800; this.timer -= 1.5; fill(20, 100); rect(10, 5, width - 20, 10); fill(255); rect(10, 5, map(this.timer, 0, 4800, 0, width - 20), 10); if (this.timer < 0) { mouseX = width / 2; mouseY = height / 2; gameOver(); } //score fill(255); textSize(46); if (punched) textSize(54); text(this.score, width / 2, 35); if (this.holesNum != this.holes.length) { this.holes = this.findHolePositions(1); } for (let i = 0; i < this.holes.length; i++) { push(); translate(this.holes[i].x, this.holes[i].y); scale(this.holes[i].d / 250); let img; switch (this.holes[i].type) { case "hole": img = imgs.hole; //nothing break; case "mole": img = imgs.mole; break; case "bomb": img = imgs.bomb; break; } if (this.holes[i].type == "mole" || this.holes[i].type == "bomb") { //check mouse click on mole if (mouseIsPressed) { if ( dist(mouseX, mouseY, this.holes[i].x, this.holes[i].y) < this.holes[i].d ) { mouseIsPressed = false; punched = true; sounds.whak.play(); setTimeout(() => { punched = false; }, 200); if (this.holes[i].type == "mole") { this.holes[i].type = "hole"; this.score += 1; this.timer += 30; } else { sounds.bomb.play(); gameOver(); } } } } image(img.img, img.xoff, img.yoff); pop(); } if (this.difficulty - this.score < 20) { this.difficulty += 30; this.holesNum += 1; } if (frameCount % (this.difficulty - this.score) == 0) { let hole = random(this.holes); if (hole.type == "mole") { hole.type = "hole"; } else { hole.type = "mole"; } if (random(1) < 0.1) { hole.type = "bomb"; setTimeout(() => { hole.type = "hole"; }, 1000); } } } findHolePositions(n, d = 200) { let arr = []; for (let i = 0; i < this.holesNum; i++) { let x = random(this.x + d / 2, this.x + this.w - d / 2); let y = random(this.y + d / 2, this.y + this.h - d / 2); arr.push({ x: x, y: y, d: d, type: "hole" }); } //no hole should overlap for (let i = 0; i < arr.length; i++) { for (let j = 0; j < arr.length; j++) { if (i != j) { let d_ = dist(arr[i].x, arr[i].y, arr[j].x, arr[j].y); if (d_ < d) { n += 1; if (n > 50) { n = 0; d *= 0.9; return this.findHolePositions(n, d); } return this.findHolePositions(n, d); } } } } return arr; } }