Jason
Published © Apache-2.0

Motion-Controlled Spaceship Game Based on XIAO nRF52840 Sens

Motion-controlled PC spaceship game using XIAO nRF52840 Sense IMU data, enabling real-time tilt and rotation control via accelerometer and g

BeginnerShowcase (no instructions)1 hour2
Motion-Controlled Spaceship Game Based on XIAO nRF52840 Sens

Things used in this project

Hardware components

Seeed Studio XIAO nRF52840 Sense (XIAO BLE Sense)
Seeed Studio XIAO nRF52840 Sense (XIAO BLE Sense)
×1

Software apps and online services

Fusion
Autodesk Fusion
Arduino IDE
Arduino IDE

Story

Read more

Schematics

3D Print File

Sketchfab still processing.

Code

XIAO nRF52840 Code

C/C++
#include <Wire.h>
#include <LSM6DS3.h>
#include <math.h>

// ===== Orientation Definitions =====
enum Orientation {
  UNKNOWN,
  FACE_UP,     // USB facing upward
  FACE_DOWN,   // USB facing downward
  LEFT,        // Tilted left
  RIGHT,       // Tilted right
  HORIZONTAL   // Flat (horizontal)
};

// ===== IMU Sensor =====
LSM6DS3 myIMU(I2C_MODE, 0x6A);

// Raw and filtered accelerometer data
float aX_raw, aY_raw, aZ_raw;
float aX_filtered = 0, aY_filtered = 0, aZ_filtered = 0;

// ===== Parameters =====
const float ANGLE_TOLERANCE_DEGREES = 35.0;
#ifndef DEG_TO_RAD
#define DEG_TO_RAD 0.01745329251994329576923690768489
#endif
const float G_THRESHOLD_HIGH = cos(ANGLE_TOLERANCE_DEGREES * DEG_TO_RAD);

const unsigned long ORIENTATION_STABLE_TIME = 10;  
const int LOOP_DELAY = 10;                         

// Low-pass filter coefficient
const float ALPHA = 0.3;

Orientation currentOrientation = UNKNOWN;
Orientation candidateOrientation = UNKNOWN;
unsigned long stableStartTime = 0;

// ===== Function Declarations =====
void initializeIMU();
void readAndFilterIMU();
Orientation detectOrientation();
void printOrientation(Orientation orientation);

// ===== Main =====
void setup() {
  Serial.begin(115200);
  while (!Serial);
  initializeIMU();
  Serial.println("--- Orientation Detection (Fast Response Mode) ---");
}

void loop() {
  readAndFilterIMU();

  Orientation newOrientation = detectOrientation();
  if (newOrientation != currentOrientation && newOrientation != UNKNOWN) {
    currentOrientation = newOrientation;
    printOrientation(currentOrientation);
  }

  delay(LOOP_DELAY);
}

// ===== Initialize IMU =====
void initializeIMU() {
  if (myIMU.begin() != 0) {
    Serial.println("Failed to initialize LSM6DS3");
  } else {
    Serial.println("LSM6DS3 initialized successfully");
  }
}

// ===== Read and Filter Accelerometer =====
void readAndFilterIMU() {
  aX_raw = myIMU.readFloatAccelX();
  aY_raw = myIMU.readFloatAccelY();
  aZ_raw = myIMU.readFloatAccelZ();

  aX_filtered = ALPHA * aX_filtered + (1 - ALPHA) * aX_raw;
  aY_filtered = ALPHA * aY_filtered + (1 - ALPHA) * aY_raw;
  aZ_filtered = ALPHA * aZ_filtered + (1 - ALPHA) * aZ_raw;
}

// ===== Orientation Detection =====
// XIAO nRF52840 standing vertically with USB on top
Orientation detectOrientation() {
  float magnitude = sqrt(aX_filtered * aX_filtered +
                         aY_filtered * aY_filtered +
                         aZ_filtered * aZ_filtered);

  Orientation newO = UNKNOWN;

  if (abs(magnitude - 1.0) <= 0.3) {
    if (aY_filtered > G_THRESHOLD_HIGH)       newO = FACE_UP;
    else if (aY_filtered < -G_THRESHOLD_HIGH) newO = FACE_DOWN;
    else if (aZ_filtered > G_THRESHOLD_HIGH)  newO = HORIZONTAL;
    else if (aX_filtered > G_THRESHOLD_HIGH)  newO = RIGHT;
    else if (aX_filtered < -G_THRESHOLD_HIGH) newO = LEFT;
  }

  if (newO != candidateOrientation) {
    candidateOrientation = newO;
    stableStartTime = millis();
  } 
  else if (millis() - stableStartTime >= ORIENTATION_STABLE_TIME) {
    return candidateOrientation;
  }

  return currentOrientation;
}

// ===== Print Orientation =====
void printOrientation(Orientation orientation) {
  switch (orientation) {
    case FACE_UP:     Serial.println("LEFT"); break;
    case FACE_DOWN:   Serial.println("RIGHT"); break;
    case LEFT:        Serial.println("UP"); break;
    case RIGHT:       Serial.println("DOWN"); break;
    case HORIZONTAL:  Serial.println("FLAT"); break;
    default: break;
  }
}

Game Code

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Interstellar Journey</title>
  <style>
    :root {
      color-scheme: dark;
      --bg: #0a0e1a;
      --fg: #e5e7eb;
      --accent: #60a5fa;
      --accent-2: #22d3ee;
      --muted: #94a3b8;
      --danger: #ef4444;
      --success: #10b981;
    }
    
    * {
      box-sizing: border-box;
    }
    
    body {
      margin: 0;
      background: radial-gradient(1200px 800px at 50% -10%, #141a33 0%, #0a0e1a 48%, #070b16 100%);
      color: var(--fg);
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      padding: 20px;
    }
    
    .container {
      width: min(1000px, 96vw);
      display: flex;
      flex-direction: column;
      gap: 16px;
    }
    
    header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      flex-wrap: wrap;
      gap: 12px;
    }
    
    h1 {
      margin: 0;
      font-size: 1.5rem;
      letter-spacing: 0.5px;
      background: linear-gradient(90deg, var(--accent), var(--accent-2));
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
      background-clip: text;
    }
    
    .controls {
      display: flex;
      gap: 10px;
      flex-wrap: wrap;
    }
    
    button {
      appearance: none;
      border: 1px solid rgba(148, 163, 184, 0.35);
      background: rgba(2, 6, 23, 0.35);
      color: var(--fg);
      border-radius: 10px;
      padding: 10px 18px;
      font-weight: 600;
      font-size: 0.95rem;
      cursor: pointer;
      transition: all 0.15s ease;
    }
    
    button:hover:not(:disabled) {
      transform: translateY(-1px);
      border-color: rgba(148, 163, 184, 0.6);
      background: rgba(2, 6, 23, 0.55);
    }
    
    button.primary {
      border-color: transparent;
      background: linear-gradient(90deg, var(--accent), var(--accent-2));
      color: #00131a;
    }
    
    button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
    
    .hud {
      display: flex;
      align-items: center;
      gap: 24px;
      font-size: 0.95rem;
      color: var(--muted);
      flex-wrap: wrap;
    }
    
    .hud strong {
      color: var(--fg);
      font-size: 1.1rem;
      margin-left: 4px;
    }
    
    .game-area {
      position: relative;
      width: 100%;
      aspect-ratio: 16 / 9;
      border-radius: 16px;
      overflow: hidden;
      background:
        radial-gradient(600px 360px at 10% 10%, rgba(96, 165, 250, 0.08) 0, transparent 60%),
        radial-gradient(600px 360px at 90% 20%, rgba(34, 211, 238, 0.06) 0, transparent 60%),
        #030617;
      box-shadow:
        inset 0 0 0 1px rgba(148, 163, 184, 0.18),
        0 18px 40px rgba(2, 6, 23, 0.9);
    }
    
    canvas {
      width: 100%;
      height: 100%;
      display: block;
    }
    
    .overlay {
      position: absolute;
      inset: 0;
      display: grid;
      place-items: center;
      background: rgba(2, 6, 23, 0.85);
      backdrop-filter: blur(8px);
    }
    
    .overlay.hidden {
      display: none;
    }
    
    .overlay-card {
      background: rgba(2, 6, 23, 0.9);
      border: 1px solid rgba(148, 163, 184, 0.24);
      border-radius: 16px;
      padding: 32px;
      text-align: center;
      max-width: 85%;
      box-shadow: 0 14px 40px rgba(2, 6, 23, 0.65);
    }
    
    .overlay-card h2 {
      margin: 0 0 12px;
      font-size: 1.4rem;
    }
    
    .overlay-card p {
      margin: 0 0 20px;
      color: var(--muted);
      line-height: 1.6;
    }
    
    .status-indicator {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      padding: 4px 10px;
      border-radius: 12px;
      background: rgba(148, 163, 184, 0.1);
      font-size: 0.85rem;
    }
    
    .status-indicator.connected {
      background: rgba(16, 185, 129, 0.2);
      color: var(--success);
    }
    
    .status-indicator.disconnected {
      background: rgba(239, 68, 68, 0.2);
      color: var(--danger);
    }
    
    .status-dot {
      width: 8px;
      height: 8px;
      border-radius: 50%;
      background: currentColor;
      animation: pulse 2s infinite;
    }
    
    @keyframes pulse {
      0%, 100% { opacity: 1; }
      50% { opacity: 0.5; }
    }
  </style>
</head>
<body>
  <div class="container">
    <header>
      <h1>🚀 Interstellar Journey · Dodge Asteroids</h1>
      <div class="controls">
        <button id="connectBtn">Connect Sensor</button>
        <button id="startBtn" class="primary" disabled>Start Game</button>
        <button id="resetBtn" disabled>Reset</button>
        <label style="display:flex;align-items:center;gap:6px;">
          Mode
          <select id="difficultySelect" style="appearance:none;border:1px solid rgba(148,163,184,0.35);background:rgba(2,6,23,0.35);color:var(--fg);border-radius:8px;padding:8px;">
            <option value="easy">Easy</option>
            <option value="normal" selected>Normal</option>
            <option value="hard">Hard</option>
          </select>
        </label>
      </div>
    </header>
    
    <div class="hud">
      <div>Time: <strong id="timeLabel">0.0s</strong></div>
      <div>Score: <strong id="scoreLabel">0</strong></div>
      <div>Level: <strong id="levelLabel">1</strong></div>
      <div>Mode: <strong id="modeLabel">Normal</strong></div>
      <div>Energy: <strong id="energyLabel">0%</strong></div>
      <div id="shieldLabel" class="status-indicator" style="padding: 2px 8px;">Shield: Inactive</div>
      <div class="status-indicator disconnected" id="sensorStatus">
        <span class="status-dot"></span>
        <span>Sensor: Not Connected</span>
      </div>
      <div id="orientationLabel" style="color: var(--muted);">Current Direction: —</div>
    </div>
    
    <div class="game-area">
      <canvas id="gameCanvas" width="1280" height="720"></canvas>
      <div class="overlay" id="overlay">
        <div class="overlay-card">
          <h2>Welcome to Interstellar Journey</h2>
          <p>Use a 6-axis sensor to control your spaceship and dodge asteroids<br>
          Tilt your device left/right/up/down to control the spaceship movement<br>
          When placed horizontally, the spaceship stops moving</p>
          <div style="display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;">
            <button id="startFromOverlay" class="primary">Start Game</button>
          </div>
        </div>
      </div>
    </div>
  </div>

  <script>
    const canvas = document.getElementById("gameCanvas");
    const ctx = canvas.getContext("2d");
    const overlay = document.getElementById("overlay");
    const connectBtn = document.getElementById("connectBtn");
    const startBtn = document.getElementById("startBtn");
    const resetBtn = document.getElementById("resetBtn");
    const startFromOverlay = document.getElementById("startFromOverlay");
    const timeLabel = document.getElementById("timeLabel");
    const scoreLabel = document.getElementById("scoreLabel");
    const levelLabel = document.getElementById("levelLabel");
    const energyLabel = document.getElementById("energyLabel");
    const sensorStatus = document.getElementById("sensorStatus");
    const orientationLabel = document.getElementById("orientationLabel");
    const shieldLabel = document.getElementById("shieldLabel");
    const difficultySelect = document.getElementById("difficultySelect");
    const modeLabel = document.getElementById("modeLabel");

    // Game state
    const gameState = {
      running: false,
      startTime: 0,
      elapsed: 0,
      score: 0,
      level: 1,
      ship: {
        x: canvas.width * 0.5,
        y: canvas.height * 0.8,
        radius: 28,
        vx: 0,
        vy: 0
      },
      meteors: [],
      spawnCooldown: 0,
      orbs: [],
      orbSpawnCooldown: 0,
      energy: 0,        // 0 - 100
      shieldActive: false,
      shieldTimeLeft: 0, // seconds
      difficulty: "normal", // easy | normal | hard
      timeScale: 1.0,     // world timescale (slowmo)
      slowmoLeft: 0,      // seconds
      orientationDirection: null, // "left", "right", "up", "down", or null
      lastSensorUpdate: 0
    };

    // Difficulty parameters
    function getDifficultyParams() {
      switch (gameState.difficulty) {
        case "easy":
          return {
            meteorSpeedMul: 0.85,
            meteorSpawnMul: 0.75, // Slower spawn
            orbSpawnMul: 1.2,     // More frequent
            shieldSeconds: 6.5,
            scoreMul: 0.9,
            slowmoSeconds: 4.0,
            slowmoFactor: 0.65
          };
        case "hard":
          return {
            meteorSpeedMul: 1.2,
            meteorSpawnMul: 1.25, // Faster spawn
            orbSpawnMul: 0.85,    // Less frequent
            shieldSeconds: 4.5,
            scoreMul: 1.2,
            slowmoSeconds: 3.0,
            slowmoFactor: 0.55
          };
        case "normal":
        default:
          return {
            meteorSpeedMul: 1.0,
            meteorSpawnMul: 1.0,
            orbSpawnMul: 1.0,
            shieldSeconds: 5.5,
            scoreMul: 1.0,
            slowmoSeconds: 4.0,
            slowmoFactor: 0.6
          };
      }
    }

    // Serial port related
    let port = null;
    let reader = null;
    let abortController = null;

    // Keyboard control (fallback)
    const keys = new Set();
    window.addEventListener("keydown", (e) => keys.add(e.key));
    window.addEventListener("keyup", (e) => keys.delete(e.key));

    // Line break transformer
    class LineBreakTransformer {
      constructor() {
        this.container = "";
      }
      transform(chunk, controller) {
        this.container += chunk;
        const lines = this.container.split(/\r?\n/);
        this.container = lines.pop() ?? "";
        for (const line of lines) {
          controller.enqueue(line);
        }
      }
      flush(controller) {
        if (this.container) {
          controller.enqueue(this.container);
        }
      }
    }

    // Connect serial port
    async function connectSerial() {
      try {
        if (!("serial" in navigator)) {
          throw new Error("Current browser does not support Web Serial API (please use Chrome/Edge desktop version)");
        }
        
        port = await navigator.serial.requestPort();
        await port.open({ baudRate: 115200 });
        
        const textDecoder = new TextDecoderStream();
        port.readable.pipeTo(textDecoder.writable);
        const inputStream = textDecoder.readable.pipeThrough(
          new TransformStream(new LineBreakTransformer())
        );
        
        reader = inputStream.getReader();
        abortController = new AbortController();
        
        sensorStatus.className = "status-indicator connected";
        sensorStatus.innerHTML = '<span class="status-dot"></span><span>Sensor: Connected</span>';
        connectBtn.disabled = true;
        startBtn.disabled = false;
        
        readSerialLoop(abortController.signal);
      } catch (err) {
        sensorStatus.className = "status-indicator disconnected";
        sensorStatus.innerHTML = '<span class="status-dot"></span><span>Sensor: Connection Failed</span>';
        alert("Connection failed: " + err.message);
      }
    }

    // Read serial data loop
    async function readSerialLoop(signal) {
      try {
        while (reader && !signal.aborted) {
          const { value, done } = await reader.read();
          if (done || signal.aborted) break;
          
          if (typeof value === "string" && value.trim()) {
            handleSensorData(value.trim());
          }
        }
      } catch (err) {
        if (err.name !== "AbortError") {
          console.warn("Failed to read serial data", err);
          sensorStatus.className = "status-indicator disconnected";
          sensorStatus.innerHTML = '<span class="status-dot"></span><span>Sensor: Read Error</span>';
        }
      }
    }

    // Handle sensor data
    function handleSensorData(line) {
      // Prefer parsing JSON format: {"orientation":"left/right/up/down/flat/unknown"}
      try {
        const json = JSON.parse(line);
        if (json.orientation && typeof json.orientation === "string") {
          const orientation = json.orientation.toLowerCase();
          handleOrientation(orientation);
          return;
        }
      } catch (e) {
        // Not JSON format, continue trying text format
      }
      
      // Parse text format: left, right, up, down, flat (legacy support)
      const trimmed = line.trim();
      if (trimmed === "朝左" || trimmed.toLowerCase() === "left") {
        handleOrientation("left");
      } else if (trimmed === "朝右" || trimmed.toLowerCase() === "right") {
        handleOrientation("right");
      } else if (trimmed === "朝上" || trimmed.toLowerCase() === "up") {
        handleOrientation("up");
      } else if (trimmed === "朝下" || trimmed.toLowerCase() === "down") {
        handleOrientation("down");
      } else if (trimmed === "水平" || trimmed.toLowerCase() === "flat") {
        handleOrientation("flat");
      }
    }

    // Handle orientation data
    function handleOrientation(orientation) {
      gameState.lastSensorUpdate = performance.now();
      
      switch (orientation) {
        case "left":
          gameState.orientationDirection = "left";
          orientationLabel.textContent = "Current Direction: ← Left";
          orientationLabel.style.color = "var(--accent)";
          break;
        case "right":
          gameState.orientationDirection = "right";
          orientationLabel.textContent = "Current Direction: → Right";
          orientationLabel.style.color = "var(--accent)";
          break;
        case "up":
          gameState.orientationDirection = "up";
          orientationLabel.textContent = "Current Direction: ↑ Up";
          orientationLabel.style.color = "var(--accent)";
          break;
        case "down":
          gameState.orientationDirection = "down";
          orientationLabel.textContent = "Current Direction: ↓ Down";
          orientationLabel.style.color = "var(--accent)";
          break;
        case "flat":
        case "unknown":
        default:
          gameState.orientationDirection = null;
          orientationLabel.textContent = "Current Direction: — Horizontal";
          orientationLabel.style.color = "var(--muted)";
          break;
      }
    }

    // Disconnect serial port
    async function disconnectSerial() {
      try {
        abortController?.abort();
        await reader?.cancel();
        await port?.close();
      } catch (err) {
        console.warn("Failed to disconnect serial port", err);
      } finally {
        reader = null;
        port = null;
        abortController = null;
        sensorStatus.className = "status-indicator disconnected";
        sensorStatus.innerHTML = '<span class="status-dot"></span><span>Sensor: Not Connected</span>';
        connectBtn.disabled = false;
        startBtn.disabled = true;
      }
    }

    // Update ship position (based on sensor)
    function updateShipBySensor(dt) {
      // Stop moving if no sensor data received for more than 500ms
      const stale = performance.now() - gameState.lastSensorUpdate > 1000;
      if (stale && gameState.orientationDirection !== null) {
        gameState.orientationDirection = null;
        orientationLabel.textContent = "Current Direction: — No Signal";
        orientationLabel.style.color = "var(--danger)";
      }
      
      // Continuous movement based on direction
      const speed = 500; // pixels/second
      let dx = 0, dy = 0;
      
      switch (gameState.orientationDirection) {
        case "left":
          dx = -speed * dt;
          break;
        case "right":
          dx = speed * dt;
          break;
        case "up":
          dy = -speed * dt;
          break;
        case "down":
          dy = speed * dt;
          break;
        case null:
        default:
          // Stop moving when placed horizontally
          break;
      }
      
      gameState.ship.x += dx;
      gameState.ship.y += dy;
      
      // Constrain to canvas
      const r = gameState.ship.radius;
      gameState.ship.x = Math.max(r, Math.min(canvas.width - r, gameState.ship.x));
      gameState.ship.y = Math.max(r, Math.min(canvas.height - r, gameState.ship.y));
    }

    // Update ship position (based on keyboard, fallback)
    function updateShipByKeyboard(dt) {
      const speed = 550;
      let dx = 0, dy = 0;
      
      if (keys.has("ArrowLeft") || keys.has("a") || keys.has("A")) dx -= 1;
      if (keys.has("ArrowRight") || keys.has("d") || keys.has("D")) dx += 1;
      if (keys.has("ArrowUp") || keys.has("w") || keys.has("W")) dy -= 1;
      if (keys.has("ArrowDown") || keys.has("s") || keys.has("S")) dy += 1;
      
      gameState.ship.x += dx * speed * dt;
      gameState.ship.y += dy * speed * dt;
      
      const r = gameState.ship.radius;
      gameState.ship.x = Math.max(r, Math.min(canvas.width - r, gameState.ship.x));
      gameState.ship.y = Math.max(r, Math.min(canvas.height - r, gameState.ship.y));
    }

    // Spawn meteor
    function spawnMeteor() {
      const d = getDifficultyParams();
      const baseSpeed = (200 + gameState.level * 45) * d.meteorSpeedMul;
      const size = Math.max(16, 36 - gameState.level * 1.5) + Math.random() * 16;
      
      const meteor = {
        x: Math.random() * canvas.width,
        y: -50,
        radius: size,
        vy: baseSpeed + Math.random() * (90 + gameState.level * 30),
        vx: (Math.random() - 0.5) * (50 + gameState.level * 20),
        rotation: Math.random() * Math.PI * 2,
        rotationSpeed: (Math.random() - 0.5) * 4
      };
      
      gameState.meteors.push(meteor);
    }

    // Update meteors
    function updateMeteors(dt) {
      // Use world time (slow motion)
      const d = getDifficultyParams();
      const worldDt = dt * gameState.timeScale;
      gameState.spawnCooldown -= worldDt;
      const spawnIntervalBase = Math.max(0.15, 0.9 - gameState.level * 0.09);
      const spawnInterval = spawnIntervalBase / d.meteorSpawnMul;
      
      if (gameState.spawnCooldown <= 0) {
        gameState.spawnCooldown = spawnInterval * (0.6 + Math.random() * 0.8);
        spawnMeteor();
      }
      
      for (const meteor of gameState.meteors) {
        meteor.x += meteor.vx * worldDt;
        meteor.y += meteor.vy * worldDt;
        meteor.rotation += meteor.rotationSpeed * worldDt;
      }
      
      // Remove meteors off screen
      const margin = 100;
      gameState.meteors = gameState.meteors.filter(m => 
        m.y - m.radius < canvas.height + margin &&
        m.x + m.radius > -margin &&
        m.x - m.radius < canvas.width + margin
      );
    }

    // Spawn energy orb
    function spawnOrb() {
      // 10% chance to spawn slow motion power-up
      const isSlowmo = Math.random() < 0.1;
      const orb = {
        x: Math.random() * canvas.width,
        y: -30,
        radius: 10 + Math.random() * 6,
        vy: 140 + Math.random() * 80,
        vx: (Math.random() - 0.5) * 30,
        hue: isSlowmo ? 280 + Math.random() * 20 : 190 + Math.random() * 70,
        type: isSlowmo ? "slowmo" : "energy"
      };
      gameState.orbs.push(orb);
    }

    // Update energy orbs
    function updateOrbs(dt) {
      const d = getDifficultyParams();
      const worldDt = dt * gameState.timeScale;
      gameState.orbSpawnCooldown -= worldDt;
      const base = Math.max(0.9, 2.4 - gameState.level * 0.12) / d.orbSpawnMul;
      if (gameState.orbSpawnCooldown <= 0) {
        gameState.orbSpawnCooldown = base * (0.7 + Math.random() * 0.8);
        // Spawn with certain probability (avoid too many)
        if (Math.random() < 0.85) spawnOrb();
      }
      for (const orb of gameState.orbs) {
        orb.x += orb.vx * worldDt;
        orb.y += orb.vy * worldDt;
      }
      // Remove off screen
      gameState.orbs = gameState.orbs.filter(o => o.y - o.radius < canvas.height + 40);
    }

    // Check collision
    function checkCollision() {
      const sx = gameState.ship.x;
      const sy = gameState.ship.y;
      const sr = gameState.ship.radius * 0.85;
      
      for (const meteor of gameState.meteors) {
        const dx = meteor.x - sx;
        const dy = meteor.y - sy;
        const distance = Math.sqrt(dx * dx + dy * dy);
        const minDistance = sr + meteor.radius;
        
        if (distance < minDistance) {
          // If shield is active, consume shield and remove the meteor, continue game
          if (gameState.shieldActive) {
            gameState.shieldActive = false;
            gameState.shieldTimeLeft = 0;
            shieldLabel.classList.remove("connected");
            shieldLabel.textContent = "Shield: Inactive";
            // Remove the currently colliding meteor
            const idx = gameState.meteors.indexOf(meteor);
            if (idx >= 0) gameState.meteors.splice(idx, 1);
            // Small score bonus
            gameState.score += 10;
            return false;
          }
          return true;
        }
      }
      
      return false;
    }

    // Update score and level
    function updateScoreAndLevel(dt) {
      gameState.elapsed += dt;
      const d = getDifficultyParams();
      // Base score (by mode multiplier)
      gameState.score += dt * (6 + gameState.level) * d.scoreMul;
      // Small combo bonus when shield is active
      if (gameState.shieldActive) {
        gameState.score += dt * 1.5;
      }
      // Shield timer
      if (gameState.shieldActive) {
        gameState.shieldTimeLeft -= dt;
        if (gameState.shieldTimeLeft <= 0) {
          gameState.shieldActive = false;
          gameState.shieldTimeLeft = 0;
          shieldLabel.classList.remove("connected");
          shieldLabel.textContent = "Shield: Inactive";
        }
      }
      // Slow motion timer
      if (gameState.slowmoLeft > 0) {
        gameState.slowmoLeft -= dt;
        if (gameState.slowmoLeft <= 0) {
          gameState.slowmoLeft = 0;
          gameState.timeScale = 1.0;
        }
      }
      
      const newLevel = 1 + Math.floor(gameState.elapsed / 12);
      if (newLevel !== gameState.level) {
        gameState.level = newLevel;
      }
      
      timeLabel.textContent = gameState.elapsed.toFixed(1) + "s";
      scoreLabel.textContent = Math.floor(gameState.score);
      levelLabel.textContent = gameState.level;
      energyLabel.textContent = Math.round(gameState.energy) + "%";
      if (gameState.shieldActive) {
        shieldLabel.classList.add("connected");
        shieldLabel.textContent = "Shield: Active";
      }
    }

    // Collect energy orbs
    function collectOrbs() {
      const sx = gameState.ship.x;
      const sy = gameState.ship.y;
      const sr = gameState.ship.radius * 0.9;
      for (let i = gameState.orbs.length - 1; i >= 0; i--) {
        const o = gameState.orbs[i];
        const dx = o.x - sx;
        const dy = o.y - sy;
        const dist2 = dx * dx + dy * dy;
        const rr = (sr + o.radius) * (sr + o.radius);
        if (dist2 < rr) {
          // Collect
          gameState.orbs.splice(i, 1);
          if (o.type === "energy") {
            gameState.energy = Math.min(100, gameState.energy + 12);
            gameState.score += 5;
            // Full energy automatically triggers shield
            if (!gameState.shieldActive && gameState.energy >= 100) {
              const d = getDifficultyParams();
              gameState.energy = 0;
              gameState.shieldActive = true;
              gameState.shieldTimeLeft = d.shieldSeconds; // seconds
              shieldLabel.classList.add("connected");
              shieldLabel.textContent = "Shield: Active";
            }
          } else if (o.type === "slowmo") {
            const d = getDifficultyParams();
            gameState.timeScale = d.slowmoFactor;
            gameState.slowmoLeft = d.slowmoSeconds;
            gameState.score += 3;
          }
        }
      }
    }

    // Draw background
    function drawBackground() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      
      // Dark background
      ctx.fillStyle = "#030617";
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      
      // Gradient overlay
      const gradient = ctx.createRadialGradient(
        canvas.width * 0.5, canvas.height * 0.4, 50,
        canvas.width * 0.5, canvas.height * 0.6, Math.max(canvas.width, canvas.height) * 0.9
      );
      gradient.addColorStop(0, "rgba(14, 23, 42, 0)");
      gradient.addColorStop(1, "rgba(2, 6, 23, 0.7)");
      ctx.fillStyle = gradient;
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      
      // Stars
      ctx.globalAlpha = 0.9;
      const stars = 100;
      for (let i = 0; i < stars; i++) {
        const x = (i * 157.33) % canvas.width;
        const y = ((i * 97.91) % canvas.height + (gameState.elapsed * 50 + i * 3) % canvas.height);
        const size = (i % 8) * 0.3 + 0.5;
        
        if (i % 11 === 0) {
          ctx.fillStyle = "#60a5fa";
        } else if (i % 17 === 0) {
          ctx.fillStyle = "#22d3ee";
        } else {
          ctx.fillStyle = "#9ca3af";
        }
        
        ctx.fillRect(x, y % canvas.height, size, size);
      }
      ctx.globalAlpha = 1.0;
    }

    // Draw ship
    function drawShip() {
      const { x, y, radius: r } = gameState.ship;
      
      ctx.save();
      ctx.translate(x, y);
      
      // Tilt based on movement direction
      let tilt = 0;
      if (gameState.orientationDirection === "left") tilt = -0.3;
      else if (gameState.orientationDirection === "right") tilt = 0.3;
      ctx.rotate(tilt);
      
      // Ship body gradient
      const shipGradient = ctx.createLinearGradient(0, -r, 0, r);
      shipGradient.addColorStop(0, "#a5d8ff");
      shipGradient.addColorStop(1, "#3b82f6");
      ctx.fillStyle = shipGradient;
      
      // Draw ship (triangle)
      ctx.beginPath();
      ctx.moveTo(0, -r * 1.8);
      ctx.lineTo(r * 1.0, r * 0.3);
      ctx.lineTo(0, r * 1.2);
      ctx.lineTo(-r * 1.0, r * 0.3);
      ctx.closePath();
      ctx.fill();
      
      // Shield aura
      if (gameState.shieldActive) {
        ctx.globalCompositeOperation = "lighter";
        const shieldG = ctx.createRadialGradient(0, 0, r * 0.9, 0, 0, r * 1.8);
        shieldG.addColorStop(0, "rgba(96, 165, 250, 0.12)");
        shieldG.addColorStop(1, "rgba(34, 211, 238, 0.0)");
        ctx.fillStyle = shieldG;
        ctx.beginPath();
        ctx.arc(0, 0, r * 1.8, 0, Math.PI * 2);
        ctx.fill();
        ctx.globalCompositeOperation = "source-over";
      }
      
      // Cockpit
      ctx.fillStyle = "rgba(255, 255, 255, 0.95)";
      ctx.beginPath();
      ctx.ellipse(0, -r * 0.8, r * 0.5, r * 0.55, 0, 0, Math.PI * 2);
      ctx.fill();
      
      // Thrust light effect
      ctx.globalCompositeOperation = "lighter";
      const thrusterGradient = ctx.createRadialGradient(
        0, r * 1.0, 0,
        0, r * 1.0, r * 1.2 + 20
      );
      thrusterGradient.addColorStop(0, "rgba(34, 211, 238, 0.95)");
      thrusterGradient.addColorStop(1, "rgba(34, 211, 238, 0)");
      ctx.fillStyle = thrusterGradient;
      ctx.beginPath();
      ctx.arc(0, r * 1.1, r * 1.0, 0, Math.PI * 2);
      ctx.fill();
      
      ctx.restore();
    }

    // Draw meteors
    function drawMeteors() {
      for (const meteor of gameState.meteors) {
        ctx.save();
        ctx.translate(meteor.x, meteor.y);
        ctx.rotate(meteor.rotation);
        
        // Meteor gradient
        const meteorGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, meteor.radius);
        meteorGradient.addColorStop(0, "#fcd34d");
        meteorGradient.addColorStop(0.5, "#f59e0b");
        meteorGradient.addColorStop(1, "#b45309");
        ctx.fillStyle = meteorGradient;
        
        // Draw polygonal meteor
        ctx.beginPath();
        const sides = 8;
        for (let i = 0; i < sides; i++) {
          const angle = (i / sides) * Math.PI * 2;
          const radius = meteor.radius * (0.75 + Math.random() * 0.5);
          const px = Math.cos(angle) * radius;
          const py = Math.sin(angle) * radius;
          if (i === 0) {
            ctx.moveTo(px, py);
          } else {
            ctx.lineTo(px, py);
          }
        }
        ctx.closePath();
        ctx.fill();
        
        ctx.restore();
      }
    }

    // Draw energy orbs
    function drawOrbs() {
      for (const o of gameState.orbs) {
        ctx.save();
        ctx.translate(o.x, o.y);
        // Glow
        ctx.globalCompositeOperation = "lighter";
        const g = ctx.createRadialGradient(0, 0, 0, 0, 0, o.radius * 2.2);
        g.addColorStop(0, `hsla(${o.hue}, 100%, 60%, 0.85)`);
        g.addColorStop(1, `hsla(${o.hue}, 100%, 60%, 0)`);
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.arc(0, 0, o.radius * 2.2, 0, Math.PI * 2);
        ctx.fill();
        // Solid core
        ctx.globalCompositeOperation = "source-over";
        ctx.fillStyle = `hsla(${o.hue}, 100%, 70%, 0.95)`;
        ctx.beginPath();
        ctx.arc(0, 0, o.radius, 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();
      }
    }

    // Slow motion screen effect
    function drawSlowmoOverlay() {
      if (gameState.slowmoLeft > 0) {
        ctx.save();
        ctx.fillStyle = "rgba(59,130,246,0.12)";
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.restore();
      }
    }

    // Main game loop
    let lastFrameTime = 0;
    function gameLoop(currentTime) {
      if (!gameState.running) return;
      
      if (!lastFrameTime) lastFrameTime = currentTime;
      const dt = Math.min(0.05, (currentTime - lastFrameTime) / 1000);
      lastFrameTime = currentTime;
      
      // Update ship (prefer sensor, otherwise keyboard)
      if (port && reader) {
        updateShipBySensor(dt);
      } else {
        updateShipByKeyboard(dt);
      }
      
      // Update game world
      updateMeteors(dt);
      updateOrbs(dt);
      updateScoreAndLevel(dt);
      collectOrbs();
      
      // Render
      drawBackground();
      drawMeteors();
      drawOrbs();
      drawShip();
      drawSlowmoOverlay();
      
      // Collision detection
      if (checkCollision()) {
        gameOver();
        return;
      }
      
      requestAnimationFrame(gameLoop);
    }

    // Start game
    function startGame() {
      gameState.running = true;
      gameState.startTime = performance.now();
      gameState.elapsed = 0;
      gameState.score = 0;
      gameState.level = 1;
      gameState.meteors = [];
      gameState.spawnCooldown = 0.1;
      gameState.orbs = [];
      gameState.orbSpawnCooldown = 0.4;
      gameState.energy = 0;
      gameState.shieldActive = false;
      gameState.shieldTimeLeft = 0;
      gameState.timeScale = 1.0;
      gameState.slowmoLeft = 0;
      gameState.ship.x = canvas.width * 0.5;
      gameState.ship.y = canvas.height * 0.8;
      gameState.orientationDirection = null;
      lastFrameTime = 0;
      
      overlay.classList.add("hidden");
      startBtn.disabled = true;
      resetBtn.disabled = false;
      
      requestAnimationFrame(gameLoop);
    }

    // Reset game
    function resetGame() {
      gameState.running = false;
      gameState.elapsed = 0;
      gameState.score = 0;
      gameState.level = 1;
      gameState.meteors = [];
      gameState.orbs = [];
      gameState.energy = 0;
      gameState.shieldActive = false;
...

This file has been truncated, please download it to see its full contents.

Credits

Jason
3 projects • 5 followers
I'm an Application Engineer specializing in smart home solutions with ESP32. I enjoy developing demos.

Comments