🎮ArcadeLab

Sky Hopper 3D

by AtomicBolt46
524 lines11.2 KB🛠️ Three.js (3D graphics)
▶ Play
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sky Hopper 3D</title>
<style>
* { box-sizing: border-box; }
body {
  margin: 0;
  overflow: hidden;
  font-family: Arial, sans-serif;
  background: #75d5ff;
  color: white;
}
canvas { display: block; }

#hud {
  position: absolute;
  top: 15px;
  left: 15px;
  display: flex;
  gap: 10px;
  z-index: 2;
}
.stat {
  background: #102a49dd;
  border: 2px solid #ffffff;
  border-radius: 12px;
  padding: 10px 15px;
  font-size: 17px;
  font-weight: bold;
}

#overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #061b32aa;
  z-index: 5;
  text-align: center;
}
.panel {
  width: min(90%, 420px);
  background: #12385b;
  border: 3px solid #ffffff;
  border-radius: 22px;
  padding: 28px;
  box-shadow: 0 0 30px #003c6a88;
}
h1 {
  margin: 0 0 10px;
  font-size: 38px;
  color: #ffe66d;
}
p { line-height: 1.5; }
button {
  padding: 14px 25px;
  background: #ffe66d;
  color: #172b42;
  font-weight: bold;
  border: 0;
  border-radius: 12px;
  font-size: 18px;
  cursor: pointer;
}
button:hover { background: white; }
.hint {
  font-size: 14px;
  color: #d6edff;
}
#flapButton {
  position: absolute;
  bottom: 25px;
  right: 25px;
  z-index: 3;
  width: 78px;
  height: 78px;
  border-radius: 50%;
  font-size: 30px;
  border: 3px solid white;
  background: #ffe66ddd;
  touch-action: none;
}
</style>
</head>
<body>

<div id="hud">
  <div class="stat">⭐ Score: <span id="score">0</span></div>
  <div class="stat">🏆 Best: <span id="best">0</span></div>
</div>

<div id="overlay">
  <div class="panel">
    <h1 id="heading">SKY HOPPER 3D</h1>
    <p id="description">
      Fly through the gaps between the pipes!
      Don't hit the pipes or fall down.
    </p>
    <p class="hint">
      SPACE / UP ARROW = FLAP<br>
      MOUSE CLICK = FLAP<br>
      MOBILE = TAP THE SCREEN
    </p>
    <button id="start">START GAME</button>
  </div>
</div>

<button id="flapButton">↑</button>

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
  }
}
</script>

<script type="module">
import * as THREE from "three";

// ---------------- SCENE ----------------
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x78d8ff);
scene.fog = new THREE.Fog(0x78d8ff, 25, 80);

const camera = new THREE.PerspectiveCamera(
  60, innerWidth / innerHeight, 0.1, 150
);
camera.position.set(0, 0, 15);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);

scene.add(new THREE.HemisphereLight(0xffffff, 0x526c88, 2));

const sunlight = new THREE.DirectionalLight(0xffffff, 2);
sunlight.position.set(-5, 10, 12);
sunlight.castShadow = true;
scene.add(sunlight);

// ---------------- MATERIALS ----------------
const birdMat = new THREE.MeshStandardMaterial({
  color: 0xffd32a,
  roughness: 0.5
});
const orangeMat = new THREE.MeshStandardMaterial({
  color: 0xff781f
});
const whiteMat = new THREE.MeshStandardMaterial({
  color: 0xffffff
});
const blackMat = new THREE.MeshStandardMaterial({
  color: 0x17202b
});
const pipeMat = new THREE.MeshStandardMaterial({
  color: 0x18a94e,
  roughness: 0.5
});
const pipeEdgeMat = new THREE.MeshStandardMaterial({
  color: 0x087b36
});

// ---------------- SKY DECORATION ----------------
const clouds = [];

function makeCloud(x, y, z) {
  const cloud = new THREE.Group();

  for (let i = 0; i < 4; i++) {
    const puff = new THREE.Mesh(
      new THREE.SphereGeometry(0.65, 16, 12),
      whiteMat
    );
    puff.position.set(i * 0.65 - 0.8, Math.sin(i) * 0.15, 0);
    puff.scale.set(1.3, 0.8, 0.5);
    cloud.add(puff);
  }

  cloud.position.set(x, y, z);
  scene.add(cloud);
  clouds.push(cloud);
}

for (let i = 0; i < 12; i++) {
  makeCloud(
    -18 + Math.random() * 40,
    -1 + Math.random() * 9,
    -8 - Math.random() * 5
  );
}

// ---------------- GROUND ----------------
const ground = new THREE.Mesh(
  new THREE.BoxGeometry(100, 1.2, 12),
  new THREE.MeshStandardMaterial({ color: 0x5bbd45 })
);
ground.position.set(0, -7.2, 0);
ground.receiveShadow = true;
scene.add(ground);

const groundTop = new THREE.Mesh(
  new THREE.BoxGeometry(100, 0.25, 12),
  new THREE.MeshStandardMaterial({ color: 0x9cdb55 })
);
groundTop.position.set(0, -6.55, 0);
scene.add(groundTop);

// ---------------- BIRD ----------------
const bird = new THREE.Group();
scene.add(bird);

const body = new THREE.Mesh(
  new THREE.SphereGeometry(0.55, 24, 18),
  birdMat
);
body.scale.set(1.2, 0.9, 0.8);
body.castShadow = true;
bird.add(body);

// Wing
const wing = new THREE.Mesh(
  new THREE.BoxGeometry(0.65, 0.16, 0.65),
  orangeMat
);
wing.position.set(-0.25, -0.1, 0.45);
bird.add(wing);

// Beak
const beak = new THREE.Mesh(
  new THREE.ConeGeometry(0.25, 0.65, 4),
  orangeMat
);
beak.rotation.z = -Math.PI / 2;
beak.position.set(0.72, -0.03, 0);
bird.add(beak);

// Eye
const eyeWhite = new THREE.Mesh(
  new THREE.SphereGeometry(0.14, 16, 12),
  whiteMat
);
eyeWhite.position.set(0.32, 0.25, 0.53);
bird.add(eyeWhite);

const pupil = new THREE.Mesh(
  new THREE.SphereGeometry(0.07, 12, 10),
  blackMat
);
pupil.position.set(0.37, 0.25, 0.64);
bird.add(pupil);

// ---------------- PIPE CREATION ----------------
let pipes = [];

function makePipePart(x, y, height, isTop) {
  const group = new THREE.Group();

  const body = new THREE.Mesh(
    new THREE.BoxGeometry(1.25, height, 1.5),
    pipeMat
  );
  body.castShadow = true;
  body.receiveShadow = true;
  group.add(body);

  const cap = new THREE.Mesh(
    new THREE.BoxGeometry(1.55, 0.32, 1.7),
    pipeEdgeMat
  );
  cap.position.y = isTop ? -height / 2 + 0.16 : height / 2 - 0.16;
  group.add(cap);

  group.position.set(x, y, 0);
  scene.add(group);
  return group;
}

function createPipePair(x) {
  const gap = Math.max(3.3, 4.2 - score * 0.015);
  const center = -0.2 + Math.random() * 2.2;

  const topHeight = 13 - (center + gap / 2);
  const bottomHeight = center - gap / 2 + 7;

  const topY = center + gap / 2 + topHeight / 2;
  const bottomY = -7 + bottomHeight / 2;

  const top = makePipePart(x, topY, topHeight, true);
  const bottom = makePipePart(x, bottomY, bottomHeight, false);

  pipes.push({
    top,
    bottom,
    x,
    gapCenter: center,
    gap,
    scored: false
  });
}

// ---------------- GAME STATE ----------------
let playing = false;
let score = 0;
let best = Number(localStorage.getItem("skyHopperBest") || 0);
let velocity = 0;
let pipeTimer = 0;
let worldSpeed = 6;
let elapsed = 0;

const scoreElement = document.getElementById("score");
const bestElement = document.getElementById("best");
const overlay = document.getElementById("overlay");

bestElement.textContent = best;

// ---------------- CONTROLS ----------------
function flap() {
  if (!playing) return;

  velocity = 8.5;
  bird.rotation.z = 0.35;
}

document.addEventListener("keydown", (e) => {
  if (
    e.code === "Space" ||
    e.code === "ArrowUp" ||
    e.code === "KeyW"
  ) {
    e.preventDefault();
    if (!e.repeat) flap();
  }
});

renderer.domElement.addEventListener("pointerdown", flap);

document.getElementById("flapButton").addEventListener(
  "pointerdown",
  (e) => {
    e.preventDefault();
    flap();
  }
);

// ---------------- COLLISION ----------------
function checkCollision() {
  const birdY = bird.position.y;

  // Top and bottom boundaries
  if (birdY > 6.2 || birdY < -6.2) {
    return true;
  }

  const birdRadius = 0.48;

  for (const pipe of pipes) {
    if (Math.abs(pipe.x) < 1.0) {
      const gapTop = pipe.gapCenter + pipe.gap / 2;
      const gapBottom = pipe.gapCenter - pipe.gap / 2;

      if (
        birdY + birdRadius > gapTop ||
        birdY - birdRadius < gapBottom
      ) {
        return true;
      }
    }
  }

  return false;
}

// ---------------- GAME OVER ----------------
function endGame() {
  playing = false;

  if (score > best) {
    best = score;
    localStorage.setItem("skyHopperBest", best);
  }

  bestElement.textContent = best;

  document.getElementById("heading").textContent = "GAME OVER!";
  document.getElementById("description").innerHTML =
    "⭐ Your score: " + score +
    "<br>🏆 Best score: " + best +
    "<br><br>Can you fly further next time?";

  document.getElementById("start").textContent = "PLAY AGAIN";
  overlay.style.display = "flex";
}

// ---------------- START GAME ----------------
function startGame() {
  pipes.forEach(pipe => {
    scene.remove(pipe.top);
    scene.remove(pipe.bottom);
  });
  pipes = [];

  score = 0;
  velocity = 0;
  pipeTimer = 0;
  worldSpeed = 6;
  elapsed = 0;

  bird.position.set(-3, 0, 0);
  bird.rotation.set(0, 0, 0);

  scoreElement.textContent = score;
  playing = true;

  document.getElementById("heading").textContent = "SKY HOPPER 3D";
  document.getElementById("description").textContent =
    "Fly through the gaps between the pipes!";

  document.getElementById("start").textContent = "START GAME";
  overlay.style.display = "none";

  createPipePair(13);
}

// ---------------- BUTTON ----------------
document.getElementById("start").addEventListener("click", startGame);

// ---------------- ANIMATION ----------------
const clock = new THREE.Clock();

function animate() {
  requestAnimationFrame(animate);

  const dt = Math.min(clock.getDelta(), 0.04);

  if (playing) {
    elapsed += dt;

    // Gravity
    velocity -= 18 * dt;
    bird.position.y += velocity * dt;

    // Tilt bird based on movement
    bird.rotation.z += (
      Math.max(-0.7, Math.min(0.5, velocity * 0.06)) -
      bird.rotation.z
    ) * Math.min(1, dt * 6);

    // Wing animation
    wing.rotation.z = Math.sin(elapsed * 18) * 0.5;

    // Gradually increase speed
    worldSpeed = Math.min(12, 6 + score * 0.07);

    // Move pipes
    pipeTimer += dt;
    if (pipeTimer > Math.max(1.45, 2.4 - score * 0.008)) {
      pipeTimer = 0;
      createPipePair(13);
    }

    for (let i = pipes.length - 1; i >= 0; i--) {
      const pipe = pipes[i];
      pipe.x -= worldSpeed * dt;

      pipe.top.position.x = pipe.x;
      pipe.bottom.position.x = pipe.x;

      // Score when passing a pipe
      if (!pipe.scored && pipe.x < -3) {
        pipe.scored = true;
        score++;
        scoreElement.textContent = score;
      }

      // Remove pipes behind the bird
      if (pipe.x < -18) {
        scene.remove(pipe.top);
        scene.remove(pipe.bottom);
        pipes.splice(i, 1);
      }
    }

    // Move clouds
    for (const cloud of clouds) {
      cloud.position.x -= worldSpeed * dt * 0.18;
      if (cloud.position.x < -23) {
        cloud.position.x = 23;
      }
    }

    if (checkCollision()) {
      endGame();
    }
  } else {
    // Gentle idle animation
    if (pipes.length === 0) {
      bird.position.y = Math.sin(clock.elapsedTime * 2) * 0.2;
      wing.rotation.z = Math.sin(clock.elapsedTime * 5) * 0.25;
    }
  }

  renderer.render(scene, camera);
}

animate();

// ---------------- RESIZE ----------------
window.addEventListener("resize", () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>

Game Source: Sky Hopper 3D

Creator: AtomicBolt46

Libraries: three

Complexity: complex (524 lines, 11.2 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: sky-hopper-3d-atomicbolt46" to link back to the original. Then publish at arcadelab.ai/publish.