404 Error Minigame
An interactive 404 error page featuring a physics-based rocket launch minigame. Users pull back a rocket on a slingshot, release to send it flying, and if it hits the target a confetti explosion celebrates the win and shows the elapsed time.
GSAPDraggablePhysicsGame404Interactive
Setup — External Scripts
GSAP CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/gsap.min.js"></script>Draggable CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/Draggable.min.js"></script>InertiaPlugin CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/InertiaPlugin.min.js"></script>Physics2DPlugin CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/Physics2DPlugin.min.js"></script>Code
index.html
html
<section data-minigame-init>
<div class="mg-wrap">
<!-- 404 text -->
<div class="mg-header">
<span class="mg-num">4</span>
<div class="mg-rocket-wrap">
<div class="mg-sling">
<div class="mg-line left" data-minigame-line="left"></div>
<div class="mg-line right" data-minigame-line="right"></div>
<div class="mg-rocket" data-minigame-rocket data-minigame-pull>
<svg viewBox="0 0 60 60" fill="none">
<ellipse cx="30" cy="38" rx="14" ry="10" fill="#3b3b3b"/>
<path d="M30 8 C18 20 16 32 16 40 Q30 48 44 40 C44 32 42 20 30 8Z" fill="#e8e8e8"/>
<ellipse cx="30" cy="40" rx="14" ry="6" fill="#c0392b"/>
<circle cx="30" cy="26" r="6" fill="#85c1e9"/>
<path d="M16 38 C10 36 6 44 10 48 L16 44Z" fill="#c0392b"/>
<path d="M44 38 C50 36 54 44 50 48 L44 44Z" fill="#c0392b"/>
</svg>
</div>
</div>
</div>
<span class="mg-num">4</span>
</div>
<!-- Target -->
<div class="mg-target-area">
<div class="mg-target" data-minigame-target>
<div class="mg-target-ring outer"></div>
<div class="mg-target-ring middle"></div>
<div class="mg-target-ring inner"></div>
</div>
</div>
<!-- Flying rocket (clone) -->
<div class="mg-fly" data-minigame-fly aria-hidden="true">
<svg viewBox="0 0 60 60" fill="none">
<ellipse cx="30" cy="38" rx="14" ry="10" fill="#3b3b3b"/>
<path d="M30 8 C18 20 16 32 16 40 Q30 48 44 40 C44 32 42 20 30 8Z" fill="#e8e8e8"/>
<ellipse cx="30" cy="40" rx="14" ry="6" fill="#c0392b"/>
<circle cx="30" cy="26" r="6" fill="#85c1e9"/>
<path d="M16 38 C10 36 6 44 10 48 L16 44Z" fill="#c0392b"/>
<path d="M44 38 C50 36 54 44 50 48 L44 44Z" fill="#c0392b"/>
</svg>
</div>
<!-- Status overlay -->
<div class="mg-status" data-minigame-status>
<p class="mg-status-label">You hit it!</p>
<p class="mg-status-time">Time: <span data-minigame-score-time></span>s</p>
<button class="mg-reset-btn" data-minigame-reset>Play again</button>
</div>
<p class="mg-hint">Pull the rocket and release!</p>
</div>
</section>styles.css
css
[data-minigame-init] {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 100svh;
background: #0f0f0f;
overflow: hidden;
font-family: sans-serif;
color: #fff;
}
.mg-wrap {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 3rem;
z-index: 1;
}
/* 404 header */
.mg-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.mg-num {
font-size: clamp(6rem, 18vw, 14rem);
font-weight: 900;
line-height: 1;
color: #fff;
letter-spacing: -0.04em;
}
/* Slingshot wrap */
.mg-rocket-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: clamp(6rem, 18vw, 14rem);
aspect-ratio: 1;
}
.mg-sling {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
/* Elastic lines */
.mg-line {
position: absolute;
bottom: 10%;
width: 2px;
background: #888;
transform-origin: bottom center;
pointer-events: none;
}
.mg-line.left { left: 20%; }
.mg-line.right { right: 20%; }
/* Rocket in sling */
.mg-rocket {
position: absolute;
width: 38%;
cursor: grab;
touch-action: none;
z-index: 2;
filter: drop-shadow(0 4px 12px rgba(0,0,0,.6));
}
.mg-rocket:active { cursor: grabbing; }
.mg-rocket svg,
.mg-fly svg {
width: 100%;
height: 100%;
display: block;
}
/* Flying clone */
.mg-fly {
position: fixed;
width: clamp(2.5rem, 5vw, 5rem);
pointer-events: none;
opacity: 0;
z-index: 100;
filter: drop-shadow(0 4px 12px rgba(0,0,0,.6));
}
/* Target */
.mg-target-area {
display: flex;
justify-content: center;
}
.mg-target {
position: relative;
width: clamp(5rem, 12vw, 9rem);
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
}
.mg-target-ring {
position: absolute;
border-radius: 50%;
border: 3px solid;
}
.mg-target-ring.outer { width: 100%; height: 100%; border-color: #e74c3c; opacity: .4; }
.mg-target-ring.middle { width: 65%; height: 65%; border-color: #e74c3c; opacity: .7; }
.mg-target-ring.inner { width: 30%; height: 30%; border-color: #e74c3c; background: #e74c3c; }
/* Win status */
.mg-status {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
background: rgba(0,0,0,.7);
backdrop-filter: blur(6px);
opacity: 0;
pointer-events: none;
z-index: 50;
border-radius: 1rem;
}
.mg-status.is-visible {
opacity: 1;
pointer-events: auto;
}
.mg-status-label {
font-size: clamp(1.5rem, 5vw, 3rem);
font-weight: 800;
color: #fff;
}
.mg-status-time {
font-size: 1rem;
color: #aaa;
}
.mg-reset-btn {
margin-top: 0.5rem;
padding: 0.6rem 1.6rem;
border: 2px solid #fff;
border-radius: 999px;
background: transparent;
color: #fff;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.mg-reset-btn:hover {
background: #fff;
color: #000;
}
.mg-hint {
font-size: 0.8rem;
color: #555;
margin-top: -1.5rem;
}
/* Confetti particle */
.mg-confetti {
position: fixed;
width: 10px;
height: 10px;
border-radius: 2px;
pointer-events: none;
z-index: 200;
}script.js
javascript
function initConfettiExplosion(originX, originY, count = 60) {
gsap.registerPlugin(Physics2DPlugin);
const colors = ["#e74c3c","#f39c12","#2ecc71","#3498db","#9b59b6","#1abc9c","#e67e22","#fff"];
for (let i = 0; i < count; i++) {
const el = document.createElement("div");
el.className = "mg-confetti";
el.style.background = colors[Math.floor(Math.random() * colors.length)];
el.style.left = originX + "px";
el.style.top = originY + "px";
document.body.appendChild(el);
const angle = Math.random() * 360;
const velocity = 400 + Math.random() * 600;
gsap.to(el, {
duration: 1.4 + Math.random() * 0.8,
physics2D: { velocity, angle, gravity: 800, friction: 0.1 },
opacity: 0,
ease: "none",
onComplete: () => el.remove(),
});
}
}
function init404Minigame() {
gsap.registerPlugin(Draggable, InertiaPlugin, Physics2DPlugin);
const CONFIG = {
maxPull: 80, // px — max drag radius
minForce: 200, // min launch velocity
maxForce: 1800, // max launch velocity
gravity: 600, // px/s²
hitRadius: 50, // collision radius in px
};
const section = document.querySelector("[data-minigame-init]");
const rocketEl = section.querySelector("[data-minigame-rocket]");
const flyEl = section.querySelector("[data-minigame-fly]");
const targetEl = section.querySelector("[data-minigame-target]");
const lineLeft = section.querySelector("[data-minigame-line='left']");
const lineRight = section.querySelector("[data-minigame-line='right']");
const statusEl = section.querySelector("[data-minigame-status]");
const timeEl = section.querySelector("[data-minigame-score-time]");
const resetBtn = section.querySelector("[data-minigame-reset]");
let startTime = null;
let flying = false;
// Origin = rocket rest position
function getRocketOrigin() {
const r = rocketEl.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
// Update elastic lines
function updateLines(dragX, dragY) {
const origin = getRocketOrigin();
const cx = origin.x + dragX;
const cy = origin.y + dragY;
[lineLeft, lineRight].forEach((line, idx) => {
const lr = line.getBoundingClientRect();
const anchorX = lr.left + lr.width / 2;
const anchorY = lr.bottom;
const dx = cx - anchorX;
const dy = cy - anchorY;
const len = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dx, -dy) * (180 / Math.PI);
gsap.set(line, { height: len, rotation: angle });
});
}
function resetLines() {
gsap.to([lineLeft, lineRight], { height: "40%", rotation: 0, duration: 0.3, ease: "elastic.out(1,0.5)" });
}
// Collision check loop
function checkCollision(raf) {
if (!flying) return;
const fr = flyEl.getBoundingClientRect();
const tr = targetEl.getBoundingClientRect();
const fCx = fr.left + fr.width / 2;
const fCy = fr.top + fr.height / 2;
const tCx = tr.left + tr.width / 2;
const tCy = tr.top + tr.height / 2;
const dist = Math.sqrt((fCx - tCx) ** 2 + (fCy - tCy) ** 2);
if (dist < CONFIG.hitRadius) {
flying = false;
gsap.killTweensOf(flyEl);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
timeEl.textContent = elapsed;
initConfettiExplosion(fCx, fCy, 80);
gsap.to(flyEl, { opacity: 0, scale: 1.5, duration: 0.3 });
statusEl.classList.add("is-visible");
return;
}
// Off-screen — reset quietly
const vw = window.innerWidth, vh = window.innerHeight;
if (fCx < -100 || fCx > vw + 100 || fCy < -100 || fCy > vh + 100) {
flying = false;
gsap.set(flyEl, { opacity: 0 });
resetRocket();
return;
}
requestAnimationFrame(checkCollision);
}
function launch(dx, dy) {
const origin = getRocketOrigin();
const dist = Math.sqrt(dx * dx + dy * dy);
const norm = CONFIG.maxPull;
const t = Math.min(dist / norm, 1);
const speed = CONFIG.minForce + t * (CONFIG.maxForce - CONFIG.minForce);
// Direction is OPPOSITE to pull
const angle = Math.atan2(-dy, -dx);
const vx = Math.cos(angle) * speed;
const vy = Math.sin(angle) * speed;
// Snap sling rocket back
gsap.to(rocketEl, { x: 0, y: 0, duration: 0.35, ease: "elastic.out(1,0.4)" });
resetLines();
// Position fly-clone at rocket origin
gsap.set(flyEl, {
left: origin.x,
top: origin.y,
xPercent: -50,
yPercent: -50,
opacity: 1,
scale: 1,
rotation: (Math.atan2(-dy, -dx) * 180 / Math.PI) - 90,
});
flying = true;
startTime = Date.now();
gsap.to(flyEl, {
duration: 3,
ease: "none",
physics2D: {
velocity: speed,
angle: (Math.atan2(-dy, -dx) * 180 / Math.PI),
gravity: CONFIG.gravity,
},
});
requestAnimationFrame(checkCollision);
}
function resetRocket() {
gsap.set(rocketEl, { x: 0, y: 0 });
}
// Draggable
Draggable.create(rocketEl, {
type: "x,y",
inertia: false,
onDrag() {
const dx = this.x, dy = this.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > CONFIG.maxPull) {
const angle = Math.atan2(dy, dx);
gsap.set(rocketEl, {
x: Math.cos(angle) * CONFIG.maxPull,
y: Math.sin(angle) * CONFIG.maxPull,
});
}
updateLines(this.x, this.y);
},
onDragEnd() {
launch(this.x, this.y);
},
});
// Reset button
resetBtn.addEventListener("click", () => {
statusEl.classList.remove("is-visible");
flying = false;
gsap.set(flyEl, { opacity: 0 });
resetRocket();
resetLines();
});
}
init404Minigame();Notes
- •The flying rocket is a fixed-position clone (`data-minigame-fly`) animated with `Physics2DPlugin` so it arcs under gravity independently of the page layout.
- •Pull distance is clamped to `CONFIG.maxPull` pixels during drag; force scales linearly from `minForce` to `maxForce` based on how far the rocket is pulled.
- •Collision detection runs in a `requestAnimationFrame` loop comparing the bounding-box centres of the flying rocket and the target ring.
- •When a hit is registered, `initConfettiExplosion` spawns 80 absolutely-positioned `div.mg-confetti` elements each driven by `Physics2DPlugin` with random angle and velocity.
- •If the rocket flies off-screen without a hit, it fades out silently and the sling resets so the player can try again.
- •The elastic sling lines are plain `div` elements whose height and rotation are updated on every drag tick via `gsap.set` — no canvas or SVG required.