Hardware components | ||||||
![]() |
| × | 1 | |||
Software apps and online services | ||||||
![]() |
| |||||
![]() |
| |||||
Motion-Controlled Spaceship Game Based on XIAO nRF52840 Sense
This project utilizes the built-in six-axis IMU sensor (accelerometer + gyroscope) of the XIAO nRF52840 Sense to enable motion-based control. It maps the device's tilt and rotation movements in real-time to the spacecraft's behavior within a computer game. Players can control the spacecraft's direction and adjust its posture simply by moving the development board, eliminating the need for a keyboard or mouse and delivering an immersive interactive experience.
The system communicates with the computer via a high-speed Bluetooth USB serial connection. The game client analyzes sensor data using a cursor and drives the 2D spacecraft's movements in real time.
This will make for a really great little game!
Project Background
It all began with a simple question:
“What if I could fly a spaceship using nothing but a tiny sensor board?”
While most people rely on keyboards to steer their in-game ships, I wanted something more immersive—something that feels like holding a sci-fi flight controller in the palm of my hand.
Then came the XIAO nRF52840 Sense, a tiny board armed with a 6-axis IMU.
And I thought: why not turn this pocket-sized device into a real-time motion controller?
That’s when the adventure started.
I taped the board to my hand (please don’t laugh), tilted it like a starfighter joystick, and watched raw IMU data swing across the screen like a roller coaster. After battling noisy acceleration data, calming gyro drift, and building a stable link to the PC, a new kind of controller was born.
The result?
A spaceship game you don’t “play”—you pilot.
Tilt to steer, roll to dodge, dive to accelerate. Like a miniature cockpit, but small enough to disappear in your pocket.
This project brings together embedded tech, sensor fusion, and creativity with one mission:to make motion control fun, intuitive, and just a little bit futuristic.
Materials Preparation
I used the following materials:
- Seeed Studio XIAO nRF52840Sense: The main control board, providing powerful processing capabilities and wireless connectivity.
3D Appearance Design
1. Download the program to the XIAO nRF52840 via Arduino
- Select the XIAO nRF52840 Sense board and proceed with downloading and programming.
2. Run game code in Cursor or Visual Studio Code
- Locate “Run” in the menu bar, select “Start Debugging, ” and enter the game interface.
3.Game Connect XIAO nRF52840 Sense Reads Six-Axis Sensor Data
- Click Connect Sensor, locate the serial port for our XIAO nRF52840, and establish the connection.
Future Improvements
While the current version already delivers a smooth and immersive motion-controlled flight experience, there are several exciting directions for future enhancements:
Advanced Sensor FusionImplement Madgwick/Mahony + adaptive filtering for even more stable attitude tracking.
- Advanced Sensor FusionImplement Madgwick/Mahony + adaptive filtering for even more stable attitude tracking.
Add UI sliders in the game to let players adjust tilt sensitivity, dead zones, and smoothing strength.
- Customizable Flight SensitivityAdd UI sliders in the game to let players adjust tilt sensitivity, dead zones, and smoothing strength.
Optimize BLE communication or switch to ESB for ultra-low-latency control.
- Wireless Low-Latency ModeOptimize BLE communication or switch to ESB for ultra-low-latency control.
Add vibration feedback on the XIAO board to simulate collisions, boosting immersion.
- Haptic FeedbackAdd vibration feedback on the XIAO board to simulate collisions, boosting immersion.
Expand the game from simple steering to full 360° space navigation.
- Full 3D Flight ModeExpand the game from simple steering to full 360° space navigation.
Battle other motion-controlled pilots and see who masters the IMU first.
- Multiplayer SupportBattle other motion-controlled pilots and see who masters the IMU first.
This project is already fun to play with—but these upgrades could take it from “cool prototype” to “next-level interactive experience.”
Final Summary
This project transforms the tiny XIAO nRF52840 Sense into a fully interactive motion controller, turning simple IMU data into an immersive spaceship-piloting experience. Through sensor fusion, real-time communication, and creative game design, it demonstrates how embedded hardware can become a powerful and intuitive input device.
It’s not just a game—it’s a blend of engineering, imagination, and experimentation.
And it proves one idea clearly:motion control doesn’t need a VR headset or expensive hardware—just a little creativity and a tiny sensor board.
#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;
}
}
<!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.





_t9PF3orMPd.png?auto=compress%2Cformat&w=40&h=40&fit=fillmax&bg=fff&dpr=2)





Comments