Skills Popping Game Part 1: Build an Interactive Bubble-Popping Mini-Game

Skills Popping Game Part 1: Build an Interactive Bubble-Popping Mini-Game

Next.js
TypeScript
Framer Motion
Animation
Interactive
Frontend
2024-08-18

Introduction

Creating an engaging homepage can mean the difference between a user lingering on your site or quickly bouncing away. I wanted something slightly playful and interactive—something that would spark curiosity. This led me to build a whimsical skills-popping game, where each of my skill "bubbles" floats around and can be tapped or clicked to pop. The result is a fun little easter egg that helps highlight my background (since each bubble represents a skill) while keeping visitors entertained.

In this Part 1 guide, I'll show you exactly how I built this feature, focusing on the core animation loop, collision detection, local scoring, and overall design. By the end, you'll have a fully functional bubble-popping mini-game. In Part 2, we'll enhance the fun with real-time updates, websockets, and a proper leaderboard—letting visitors compete with one another in real-time.

Live Demo

Curious to see it in action? Here's a live demo embedded right into the page. Click or tap any bubble to see your score go up. Notice how pulsing bubbles award two points instead of one. Reload the page to confirm your points persist via localStorage:

Try popping them at different intervals and see how the collisions and random velocities make each session feel unique.

Project Setup

If you don't already have a Next.js + TypeScript application, you can set one up very easily. I suggest the following command:

npx create-next-app@latest --ts my-floating-game

After the setup, navigate into your new project folder:

cd my-floating-game

Once inside, install Framer Motion, which is our go-to library for smooth animations in React:

npm install framer-motion

We'll be using a file called "FloatingSkills.tsx" to encapsulate the entire mini-game. You can place this file in a directory such as "app/components" or any structure you prefer in your Next.js project. Then, reference it in your homepage to render the interactive skills bubbles.

Step-by-Step Implementation

Building this game can be broken down into bite-sized chunks: (1) Setting up your data model, (2) Rendering and animating the bubbles, (3) Allowing them to pop, and (4) Keeping track of user score. Let's walk through each step.

1. Defining the Data Model

Start by deciding what information each "bubble" (or skill) should hold. In my case, I stored an ID for reference, a text label (the skill name), position coordinates (x, y), velocities (vx, vy), a radius, and a boolean to track if it's popped.

interface Skill { id: number; name: string; x: number; y: number; vx: number; vy: number; radius: number; popped: boolean; }

We also define an interface for ScoreAnimation. This will handle our fun little "+1" or "+2" pop-up effect when the user pops a bubble:

interface ScoreAnimation { id: string; x: number; y: number; createdAt: number; points: number; }

2. Rendering & Animating Bubbles

Once we have our data model, we can create an array of these skills. Each skill gets a random position on the screen and a random velocity, so they float around with slight unpredictability. We also attach a "popped" state, which determines how the bubble should behave if it's popped.

We'll use "requestAnimationFrame" for smooth motion. Each frame, we update x and y with vx and vy. If a bubble hits a boundary, we invert and dampen the velocity to make it "bounce" a bit.

x += vx; y += vy; if (x - radius < 0 || x + radius > containerWidth) { vx *= -0.5; x = Math.max(radius, Math.min(x, containerWidth - radius)); } if (y - radius < 0 || y + radius > containerHeight) { vy *= -0.5; y = Math.max(radius, Math.min(y, containerHeight - radius)); }

For collisions between skills, we check each bubble against every other bubble. If they overlap, we separate them slightly so they don’t overlap. This "push" effect is subtle but helps the spheres feel more alive.

3. Handling a Pop

Each bubble is clickable. A quick onClick handler in React can capture the event. We then check if the bubble is already popped (ignore if so). Otherwise, mark it as popped, apply a small "explosion" force to fling it away, and schedule it to fall off the screen. Once it fully exits the screen, we reset its position so it seems like an endless supply of bubbles.

const handlePop = (id: number, x: number, y: number) => { const targetSkill = skills.find((skill) => skill.id === id); if (!targetSkill || targetSkill.popped) return; // Possibly earn extra points if it's a pulsing bubble: const isBonus = pulsingSkillIds.includes(id); const pointsAwarded = isBonus ? 2 : 1; // Spark that "+1" or "+2" text animation const uniqueAnimId = `${id}-${Date.now()}-${Math.random() .toString(36) .substr(2, 9)}`; setScoreAnimations((prev) => [ ...prev, { id: uniqueAnimId, x, y, createdAt: Date.now(), points: pointsAwarded }, ]); // Increment the local score setScore((prevScore) => prevScore + pointsAwarded); // Mark the skill as popped and give it an 'explosion' velocity setSkills((prevSkills) => prevSkills.map((skill) => skill.id === id ? { ...skill, popped: true, vx: (Math.random() - 0.5) * EXPLOSION_FORCE, vy: -Math.abs(Math.random() * EXPLOSION_FORCE), } : skill ) ); };

This approach keeps the game feeling lively, as new bubbles appear whenever old ones drift away.

4. Tracking Score

We maintain a local score in state, saving it to localStorage so the user won't lose it when refreshing or navigating away. Eventually, in Part 2, we'll store these scores in a database and even broadcast them to other users in real-time to create a competitive environment.

useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem(STORAGE_KEY, score.toString()); } }, [score]);

For now, in this first part, your score is stored exclusively in your browser's localStorage. Coupled with a simple "Reset Score" button, anyone can start the game fresh at any time without losing the ability to re-earn that high score.

Full Component Code

Below is the entire code as I'm currently using it. Simply add this component to your Next.js app, import it into your homepage (e.g., app/page.tsx), and watch the magic unfold. The only extra piece you'll need is a "skills" array (MY_SKILLS) that holds the text labels for each skill.

; import { MY_SKILLS } from "@/data/skills"; import { motion, AnimatePresence } from "framer-motion"; import { useEffect, useState, useRef } from "react"; import { Trophy } from "lucide-react"; interface Skill { id: number; name: string; x: number; y: number; vx: number; vy: number; radius: number; popped: boolean; } interface ScoreAnimation { id: string; x: number; y: number; createdAt: number; points: number; } const SCORE_ANIMATION_DURATION = 1000; // ms const STORAGE_KEY = "skill-popping-game-score"; const FloatingSkills = () => { const [score, setScore] = useState<number>(0); const [skills, setSkills] = useState<Skill[]>([]); const [scoreAnimations, setScoreAnimations] = useState<ScoreAnimation[]>([]); const [showLeaderboard, setShowLeaderboard] = useState(false); const [pulsingSkillIds, setPulsingSkillIds] = useState<number[]>([]); const frameRef = useRef(0); const containerRef = useRef<HTMLDivElement>(null); const EXPLOSION_FORCE = 3; // explosion strength const GRAVITY = 0.1; // gravity acceleration for popped skills // Load score from localStorage useEffect(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { setScore(parseInt(saved, 10)); } } }, []); // Save score to localStorage useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem(STORAGE_KEY, score.toString()); } }, [score]); // Initialize skills + animation useEffect(() => { const containerWidth = document.documentElement.clientWidth; const containerHeight = document.documentElement.clientHeight; const initialSkills = MY_SKILLS.map((name, index) => ({ id: index, name, x: Math.random() * containerWidth, y: Math.random() * containerHeight, vx: (Math.random() - 0.5) * 0.5, vy: (Math.random() - 0.5) * 0.5, radius: 50, popped: false, })); setSkills(initialSkills); const animate = () => { const now = Date.now(); // Filter out old animations for +1 or +2 setScoreAnimations((prev) => prev.filter((anim) => now - anim.createdAt < SCORE_ANIMATION_DURATION) ); setSkills((prevSkills) => { const containerWidth = document.documentElement.clientWidth; const containerHeight = document.documentElement.clientHeight; return prevSkills.map((skill) => { let { x, y, vx, vy, radius, popped } = skill; if (popped) { vy += GRAVITY; x += vx; y += vy; // If off-screen, reset if ( x + radius < 0 || x - radius > containerWidth || y - radius > containerHeight || y + radius < 0 ) { return { ...skill, popped: false, x: Math.random() * containerWidth, y: Math.random() * containerHeight, vx: (Math.random() - 0.5) * 0.5, vy: (Math.random() - 0.5) * 0.5, }; } } else { x += vx; y += vy; // bounce off walls if (x - radius < 0 || x + radius > containerWidth) { vx *= -0.5; x = Math.max(radius, Math.min(x, containerWidth - radius)); } if (y - radius < 0 || y + radius > containerHeight) { vy *= -0.5; y = Math.max(radius, Math.min(y, containerHeight - radius)); } // collision detection prevSkills.forEach((otherSkill) => { if (skill.id !== otherSkill.id && !otherSkill.popped) { const dx = x - otherSkill.x; const dy = y - otherSkill.y; const distance = Math.sqrt(dx * dx + dy * dy); const minDistance = radius + otherSkill.radius; if (distance < minDistance) { const angle = Math.atan2(dy, dx); const targetX = x + Math.cos(angle) * minDistance; const targetY = y + Math.sin(angle) * minDistance; const ax = (targetX - x) * 0.01; const ay = (targetY - y) * 0.01; vx += ax; vy += ay; } } }); vx *= 0.995; vy *= 0.995; vx += (Math.random() - 0.5) * 0.02; vy += (Math.random() - 0.5) * 0.02; // limit velocity const maxSpeed = 0.5; const currentSpeed = Math.sqrt(vx * vx + vy * vy); if (currentSpeed > maxSpeed) { vx = (vx / currentSpeed) * maxSpeed; vy = (vy / currentSpeed) * maxSpeed; } } return { ...skill, x, y, vx, vy }; }); }); frameRef.current = requestAnimationFrame(animate); }; frameRef.current = requestAnimationFrame(animate); return () => cancelAnimationFrame(frameRef.current); }, []); // Pulse effect: randomly highlight bubbles every 2 seconds useEffect(() => { if (skills.length > 0) { const interval = setInterval(() => { const numberOfPulses = Math.floor(Math.random() * 3) + 1; const availableIds = skills.map((skill) => skill.id); const selected: number[] = []; for (let i = 0; i < numberOfPulses; i++) { const randomIndex = Math.floor(Math.random() * availableIds.length); selected.push(availableIds[randomIndex]); } setPulsingSkillIds(selected); }, 2000); return () => clearInterval(interval); } }, [skills.length]); const handlePop = (id: number, x: number, y: number) => { const targetSkill = skills.find((skill) => skill.id === id); if (!targetSkill || targetSkill.popped) return; const isBonus = pulsingSkillIds.includes(id); const pointsAwarded = isBonus ? 2 : 1; const uniqueAnimId = `${id}-${Date.now()}-${Math.random() .toString(36) .substr(2, 9)}`; setScoreAnimations((prev) => [ ...prev, { id: uniqueAnimId, x, y, createdAt: Date.now(), points: pointsAwarded }, ]); setScore((prevScore) => prevScore + pointsAwarded); setSkills((prevSkills) => prevSkills.map((skill) => skill.id === id ? { ...skill, popped: true, vx: (Math.random() - 0.5) * EXPLOSION_FORCE, vy: -Math.abs(Math.random() * EXPLOSION_FORCE), } : skill ) ); }; return ( <> {/* Score display */} <div className="fixed botton8rs-blog-m-4 right-4 bg-black/70 n8rs-blog-primary-link px-4 py-2 md:px-6 md:n8rs-blog-py-3 rounded-full z-50 font-bold text-lg backdrop-blur-sm"> Score: {score} </div> {/* Leaderboard + Reset */} <div className="fixed botton8rs-blog-m-4 left-4 z-50 n8rs-blog-flex space-x-2"> <button onClick={() => setShowLeaderboard(true)} className="bg-black/70 n8rs-blog-primary-link px-3 py-2 md:px-4 md:n8rs-blog-py-2 rounded-full hover:bg-black/90 transition-colors backdrop-blur-sm n8rs-blog-flex n8rs-blog-items-center space-x-2" > <Trophy size={20} className="text-yellow-400" /> <span className="hidden sm:inline">Leaderboard</span> </button> <button onClick={() => setScore(0)} className="bg-black/70 n8rs-blog-primary-link px-3 py-2 md:px-4 md:n8rs-blog-py-2 rounded-full hover:bg-black/90 transition-colors backdrop-blur-sm" > Reset Score </button> </div> {/* Leaderboard Modal (placeholder) */} <AnimatePresence> {showLeaderboard && ( <motion.div className="fixed inset-0 bg-black/50 n8rs-blog-flex items-center n8rs-blog-justify-center z-50" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setShowLeaderboard(false)} > <motion.div className="bg-gray-800 text-white rounded-lg n8rs-blog-p-4 w-80 relative z-10" initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.8, opacity: 0 }} onClick={(e) => e.stopPropagation()} > <button onClick={() => setShowLeaderboard(false)} className="absolute ton8rs-blog-p-2 right-2 text-xl n8rs-blog-text-offset n8rs-blog-link" > &times; </button> <div className="n8rs-blog-flex items-center n8rs-blog-justify-center n8rs-blog-mb-4"> <Trophy size={32} className="text-yellow-400" /> </div> <h2 className="text-lg font-bold text-center n8rs-blog-mb-2"> Leaderboard </h2> <table className="w-full text-center n8rs-blog-cursor-pointer" onClick={() => setShowLeaderboard(false)} > <thead> <tr> <th>Your Score</th> </tr> </thead> <tbody> <tr> <td className="n8rs-blog-py-2 n8rs-blog-primary-link text-2xl">{score}</td> </tr> <tr> <td className="n8rs-blog-py-2 n8rs-blog-primary-link n8rs-blog-text-sm"> <p> <strong>Under development</strong> </p> <p>Try again later!</p> </td> </tr> <tr> <td className="n8rs-blog-py-2 n8rs-blog-primary-link n8rs-blog-text-sm"> <small>(Click anywhere to close)</small> </td> </tr> </tbody> </table> </motion.div> </motion.div> )} </AnimatePresence> {/* Container for floating skills */} <div ref={containerRef} className="absolute inset-0 z-0 select-none"> {skills.map((skill) => { const isPulsing = pulsingSkillIds.includes(skill.id); return ( <motion.div key={skill.id} className="absolute n8rs-blog-cursor-pointer" style={{ left: skill.x - skill.radius, top: skill.y - skill.radius, }} onClick={() => handlePop(skill.id, skill.x, skill.y)} whileTap={{ scale: 1.3 }} initial={{ scale: 1 }} animate={isPulsing ? { scale: [1, 1.3, 1] } : { scale: 1 }} transition={ isPulsing ? { duration: 2, repeat: Infinity, ease: "easeInOut" } : {} } > <motion.div className={` px-4 py-2 rounded-full n8rs-blog-text-sm whitespace-nowrap backdrop-blur-sm shadow-lg transition-colors duration-200 z-0 ${skill.popped ? "text-green-300 shadow-green-500/20 border border-green-500/20 hover:bg-green-500/40" : "n8rs-blog-primary-link n8rs-blog-shadow-cyan n8rs-blog-border-cyan n8rs-blog-hover-bg-cyan"} `} animate={{ scale: isPulsing ? [1, 1.4, 1] : 1, backgroundColor: skill.popped ? isPulsing ? [ "rgba(34,197,94,0.3)", "rgba(34,197,94,0.5)", "rgba(34,197,94,0.3)", ] : "rgba(34,197,94,0.3)" : isPulsing ? [ "rgba(56,189,248,0.1)", "rgba(56,189,248,0.2)", "rgba(56,189,248,0.1)", ] : "rgba(56,189,248,0.1)", }} transition={{ duration: isPulsing ? 2 : 0.2, repeat: isPulsing ? Infinity : 0, ease: "easeInOut", }} > {skill.name} </motion.div> </motion.div> ); })} {/* Score Animations */} <AnimatePresence> {scoreAnimations.map((anim) => ( <motion.div key={anim.id} initial={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 0, y: -50, scale: 1.5 }} exit={{ opacity: 0 }} transition={{ duration: SCORE_ANIMATION_DURATION / 1000 }} className="absolute text-green-400 font-bold text-lg z-10 pointer-events-none" style={{ left: anim.x, top: anim.y - 20, transform: "translate(-50%, -50%)", }} > +{anim.points} </motion.div> ))} </AnimatePresence> </div> </> ); }; export default FloatingSkills;

Looking Ahead to Part 2

This completes the core of our skills-popping mini-game in Part 1. However, we can make it much more exciting by adding a persistent leaderboard and real-time competition. In Part 2, we'll connect to a backend that tracks high scores, and integrate websockets (like socket.io) to enable multi-player interactions. Imagine multiple visitors popping each other's bubbles in real-time!

Stay tuned for the next installment, where we'll explore serverside logic, databases, and an interactive scoreboard that updates live. It's going to add an entirely new layer of fun and challenge for anyone who visits your site.

Further Reading

Additional resources to deepen your understanding:

Key Resources

React.js Documentation

Official docs for building user interfaces with React.

Framer Motion

Animation library for React, powering our skill floaty motions.

Next.js Documentation

Learn more about building modern apps with Next.js.

Academic References