Hardware components | ||||||
![]() |
| × | 1 | |||
Software apps and online services | ||||||
![]() |
| |||||
![]() |
| |||||
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 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 BackgroundIt 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.
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.
While the current version already delivers a smooth and immersive motion-controlled flight experience, there are several exciting directions for future enhancements:
Advanced Sensor Fusion
Implement Madgwick/Mahony + adaptive filtering for even more stable attitude tracking.
- Advanced Sensor FusionImplement Madgwick/Mahony + adaptive filtering for even more stable attitude tracking.
Customizable Flight Sensitivity
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.
Haptic Feedback
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.
Full 3D Flight Mode
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.
Multiplayer Support
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 SummaryThis 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 boar
#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