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.