Space Mystery 3D - Expanded
by AtomicBolt46929 lines21.4 KB🛠️ Three.js (3D graphics)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Space Mystery 3D - Expanded</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
overflow: hidden;
background: #080d1c;
color: white;
font-family: Arial, sans-serif;
}
canvas { display: block; }
#hud {
position: absolute;
top: 12px;
left: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
z-index: 2;
}
.stat {
background: #101b30ee;
border: 2px solid #38d9ff;
padding: 9px 12px;
border-radius: 10px;
font-weight: bold;
}
#taskBox {
position: absolute;
top: 65px;
left: 12px;
width: 220px;
background: #101b30ee;
border: 2px solid #38d9ff;
padding: 12px;
border-radius: 10px;
z-index: 2;
font-size: 14px;
line-height: 1.7;
}
#prompt {
position: absolute;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
background: #101b30ee;
border: 2px solid #ffe36b;
border-radius: 10px;
padding: 10px 16px;
font-weight: bold;
z-index: 3;
display: none;
text-align: center;
}
#controls {
position: absolute;
bottom: 12px;
left: 12px;
background: #101b30cc;
border-radius: 10px;
padding: 10px;
font-size: 12px;
z-index: 2;
line-height: 1.5;
}
#mobile {
position: absolute;
right: 12px;
bottom: 12px;
z-index: 3;
display: grid;
grid-template-columns: repeat(3, 46px);
grid-template-rows: repeat(2, 46px);
gap: 4px;
}
.move {
background: #142b48ee;
color: white;
border: 2px solid #38d9ff;
border-radius: 10px;
padding: 0;
margin: 0;
font-size: 20px;
touch-action: none;
}
#up { grid-column: 2; grid-row: 1; }
#left { grid-column: 1; grid-row: 2; }
#down { grid-column: 2; grid-row: 2; }
#right { grid-column: 3; grid-row: 2; }
#actions {
position: absolute;
right: 12px;
bottom: 120px;
display: flex;
flex-direction: column;
z-index: 3;
}
.action {
background: #b52d42;
color: white;
border: 2px solid white;
border-radius: 50%;
width: 66px;
height: 66px;
font-size: 12px;
margin: 5px;
padding: 3px;
}
#overlay {
position: absolute;
inset: 0;
background: #050a17dd;
z-index: 5;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
padding: 12px;
}
.panel {
background: #14233b;
border: 3px solid #38d9ff;
border-radius: 20px;
padding: 22px;
width: min(94%, 470px);
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 0 30px #00cfff44;
}
h1 { color: #38d9ff; margin: 0 0 12px; }
button {
background: #38d9ff;
border: 0;
border-radius: 10px;
padding: 12px 18px;
margin: 6px;
color: #071321;
font-weight: bold;
font-size: 15px;
cursor: pointer;
}
button:hover { background: white; }
.voteButton {
display: block;
width: 100%;
margin: 7px 0;
color: white;
background: #263b5d;
border: 2px solid #38d9ff;
}
.small {
color: #c8d7ea;
font-size: 13px;
line-height: 1.6;
}
</style>
</head>
<body>
<div id="hud">
<div class="stat">🚀 SPACE MYSTERY</div>
<div class="stat">Tasks: <span id="taskCount">0/4</span></div>
<div class="stat">Crew: <span id="crewCount">9</span></div>
</div>
<div id="taskBox">
<b>YOUR MISSIONS</b>
<div id="taskList"></div>
<hr style="border-color:#40516c">
<span id="status">Explore the spaceship!</span>
</div>
<div id="prompt"></div>
<div id="controls">
WASD / ARROWS = Move<br>
E = Complete task<br>
R = Report nearby incident<br>
V = Emergency meeting
</div>
<div id="actions">
<button class="action" id="reportBtn">REPORT</button>
<button class="action" id="meetingBtn" style="background:#d28c27">MEETING</button>
</div>
<div id="mobile">
<button class="move" id="up">▲</button>
<button class="move" id="left">◀</button>
<button class="move" id="down">▼</button>
<button class="move" id="right">▶</button>
</div>
<div id="overlay">
<div class="panel" id="panel">
<h1>SPACE MYSTERY 3D</h1>
<p>Explore a huge spaceship with 8 other crewmates!</p>
<p class="small">
Some crewmates are secretly impostors. They may eliminate other crew
members and try to avoid being discovered.
Find clues, complete your tasks and vote out the impostors!
</p>
<p class="small">
WASD = Move · E = Task · R = Report · V = Meeting
</p>
<button id="start">START MISSION</button>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
// ---------------- SCENE ----------------
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080f24);
scene.fog = new THREE.Fog(0x080f24, 40, 95);
const camera = new THREE.PerspectiveCamera(
55, innerWidth / innerHeight, 0.1, 150
);
camera.position.set(0, 28, 30);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
scene.add(new THREE.HemisphereLight(0x9bbfff, 0x29304b, 2));
const light = new THREE.DirectionalLight(0xffffff, 2);
light.position.set(-12, 25, 15);
light.castShadow = true;
scene.add(light);
// ---------------- MATERIALS ----------------
const mat = (color, emissive = 0x000000) =>
new THREE.MeshStandardMaterial({
color, emissive, roughness: 0.65
});
const floorMat = mat(0x26354d);
const wallMat = mat(0x182943);
const cyanMat = mat(0x38d9ff, 0x075b77);
const screenMat = mat(0x0b203b, 0x063b65);
// ---------------- BIG MAP ----------------
const MAP_W = 50;
const MAP_H = 38;
const floor = new THREE.Mesh(
new THREE.BoxGeometry(MAP_W, 0.5, MAP_H),
floorMat
);
floor.position.y = -0.35;
floor.receiveShadow = true;
scene.add(floor);
const grid = new THREE.GridHelper(50, 25, 0x42658c, 0x344b6b);
grid.position.y = -0.08;
grid.scale.z = 0.76;
scene.add(grid);
function wall(x, z, width, depth) {
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(width, 3.5, depth),
wallMat
);
mesh.position.set(x, 1.3, z);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
}
// Outer walls
wall(0, -19, 50, 0.8);
wall(0, 19, 50, 0.8);
wall(-25, 0, 0.8, 38);
wall(25, 0, 0.8, 38);
// Interior walls create different spaceship rooms.
// Gaps are left for doorways.
wall(-12, -10, 0.7, 14);
wall(-12, 9, 0.7, 16);
wall(12, -10, 0.7, 14);
wall(12, 9, 0.7, 16);
wall(0, -7, 18, 0.7);
wall(0, 8, 18, 0.7);
wall(-19, 0, 0.7, 15);
wall(19, 0, 0.7, 15);
// Glowing wall strips
for (const z of [-18.5, 18.5]) {
const strip = new THREE.Mesh(
new THREE.BoxGeometry(48, 0.12, 0.12),
cyanMat
);
strip.position.set(0, 1.3, z);
scene.add(strip);
}
// Room decorations
function makeCrate(x, z) {
const crate = new THREE.Mesh(
new THREE.BoxGeometry(1.7, 1.5, 1.7),
mat(0x455b78)
);
crate.position.set(x, 0.45, z);
crate.castShadow = true;
scene.add(crate);
}
[
[-21,-15], [21,-15], [-21,15], [21,15],
[-6,-15], [7,-15], [-6,15], [7,15],
[-17,4], [17,-4]
].forEach(p => makeCrate(p[0], p[1]));
// ---------------- CREWMATE ----------------
function makeCrew(color, x, z) {
const group = new THREE.Group();
const suit = mat(color);
const body = new THREE.Mesh(
new THREE.CylinderGeometry(0.55, 0.62, 1.25, 20),
suit
);
body.position.y = 0.9;
body.castShadow = true;
group.add(body);
const helmet = new THREE.Mesh(
new THREE.SphereGeometry(0.57, 24, 18),
suit
);
helmet.position.y = 1.55;
helmet.scale.set(1, 0.9, 0.9);
helmet.castShadow = true;
group.add(helmet);
const visor = new THREE.Mesh(
new THREE.SphereGeometry(0.35, 20, 14),
new THREE.MeshStandardMaterial({
color: 0x8ceaff,
metalness: 0.7,
roughness: 0.18
})
);
visor.position.set(0, 1.62, 0.46);
visor.scale.set(1.25, 0.65, 0.5);
group.add(visor);
const pack = new THREE.Mesh(
new THREE.BoxGeometry(0.65, 0.8, 0.35),
suit
);
pack.position.set(0, 0.9, -0.58);
group.add(pack);
group.position.set(x, 0, z);
scene.add(group);
return group;
}
// ---------------- PLAYER ----------------
const player = makeCrew(0x21d5e9, 0, 0);
let playerX = 0;
let playerZ = 0;
// ---------------- BOTS ----------------
const colors = [
0xff5757, 0x9b70ff, 0xffc83d, 0x24cf77,
0xff8c42, 0xff70bb, 0x9aa9bd, 0x7cdeea
];
const names = [
"RED", "PURPLE", "YELLOW", "GREEN",
"ORANGE", "PINK", "WHITE", "CYAN"
];
let bots = [];
let impostors = [];
let evidence = [];
let tasks = [];
let consoles = [];
function createBots() {
bots = [];
impostors = [];
// Two randomly selected impostors.
const shuffled = [...Array(8).keys()].sort(() => Math.random() - 0.5);
const impSet = new Set(shuffled.slice(0, 2));
const positions = [
[-18,-12], [-6,-12], [6,-12], [18,-12],
[-18,12], [-6,12], [6,12], [18,12]
];
for (let i = 0; i < 8; i++) {
const [x,z] = positions[i];
const bot = {
mesh: makeCrew(colors[i], x, z),
name: names[i],
color: colors[i],
x, z,
targetX: x,
targetZ: z,
timer: Math.random() * 3,
alive: true,
isImpostor: impSet.has(i),
cooldown: 3 + Math.random() * 5,
phase: Math.random() * 5
};
bots.push(bot);
if (bot.isImpostor) impostors.push(bot);
}
}
// ---------------- TASKS ----------------
function createTasks() {
tasks = [
{ name: "Communications", x: -20, z: 0, done: false },
{ name: "Download data", x: 19, z: -12, done: false },
{ name: "Engine calibration", x: 0, z: 14, done: false },
{ name: "Repair navigation", x: 0, z: -14, done: false }
];
consoles = [];
tasks.forEach(task => {
const group = new THREE.Group();
const stand = new THREE.Mesh(
new THREE.BoxGeometry(1.6, 1.1, 1),
mat(0x40536c)
);
stand.position.y = 0.55;
group.add(stand);
const screen = new THREE.Mesh(
new THREE.BoxGeometry(1.4, 0.9, 0.12),
screenMat
);
screen.position.set(0, 1.5, 0.45);
group.add(screen);
group.position.set(task.x, 0, task.z);
scene.add(group);
const marker = new THREE.Mesh(
new THREE.TorusGeometry(0.8, 0.06, 8, 28),
mat(0xffe16b, 0x805000)
);
marker.rotation.x = Math.PI / 2;
marker.position.set(task.x, 0.05, task.z);
scene.add(marker);
consoles.push({ task, marker, group });
});
}
// ---------------- ELIMINATION MARKERS ----------------
// These are harmless hologram beacons, not graphic character models.
function makeEvidence(bot) {
const group = new THREE.Group();
const ring = new THREE.Mesh(
new THREE.TorusGeometry(0.7, 0.12, 8, 24),
mat(bot.color, bot.color)
);
ring.rotation.x = Math.PI / 2;
ring.position.y = 0.15;
group.add(ring);
const beam = new THREE.Mesh(
new THREE.CylinderGeometry(0.08, 0.08, 1.1, 12),
new THREE.MeshBasicMaterial({
color: bot.color,
transparent: true,
opacity: 0.6
})
);
beam.position.y = 0.7;
group.add(beam);
group.position.set(bot.x, 0, bot.z);
scene.add(group);
evidence.push({
mesh: group,
x: bot.x,
z: bot.z,
reported: false,
victim: bot.name
});
}
function eliminateBot(bot) {
if (!bot.alive) return;
bot.alive = false;
scene.remove(bot.mesh);
makeEvidence(bot);
updateCrewCount();
}
function updateCrewCount() {
const alive = bots.filter(b => b.alive).length + 1;
document.getElementById("crewCount").textContent = alive;
}
// ---------------- TASK UI ----------------
let tasksDone = 0;
function updateTasks() {
tasksDone = tasks.filter(t => t.done).length;
document.getElementById("taskCount").textContent =
tasksDone + "/4";
document.getElementById("taskList").innerHTML =
tasks.map(t => (t.done ? "✅ " : "☐ ") + t.name).join("<br>");
document.getElementById("status").textContent =
tasksDone === 4
? "All tasks complete! Find the impostors."
: "Explore the ship and finish your tasks.";
}
let nearestTask = null;
let nearestEvidence = null;
const prompt = document.getElementById("prompt");
function checkNearby() {
nearestTask = null;
nearestEvidence = null;
let closestTask = 2.8;
let closestEvidence = 2.8;
for (const task of tasks) {
if (task.done) continue;
const d = Math.hypot(playerX - task.x, playerZ - task.z);
if (d < closestTask) {
closestTask = d;
nearestTask = task;
}
}
for (const item of evidence) {
if (item.reported) continue;
const d = Math.hypot(playerX - item.x, playerZ - item.z);
if (d < closestEvidence) {
closestEvidence = d;
nearestEvidence = item;
}
}
if (nearestEvidence) {
prompt.textContent = "Press R to report " + nearestEvidence.victim;
prompt.style.display = "block";
} else if (nearestTask) {
prompt.textContent = "Press E to use " + nearestTask.name;
prompt.style.display = "block";
} else {
prompt.style.display = "none";
}
}
function interact() {
if (!playing || !nearestTask) return;
nearestTask.done = true;
const c = consoles.find(c => c.task === nearestTask);
if (c) c.marker.material = mat(0x26ff9a, 0x087c43);
updateTasks();
checkNearby();
}
function reportEvidence(item) {
if (!playing || !item || item.reported) return;
item.reported = true;
showMeeting("You reported " + item.victim + "!");
}
// ---------------- BOT AI ----------------
let playing = false;
let meetingOpen = false;
let gameFinished = false;
let meetingTimer = 0;
let meetingMessage = "";
function moveBots(dt, time) {
for (const bot of bots) {
if (!bot.alive) continue;
bot.timer -= dt;
bot.cooldown -= dt;
if (bot.timer <= 0) {
bot.timer = 2 + Math.random() * 3;
// Bots wander around the larger ship.
bot.targetX = -21 + Math.random() * 42;
bot.targetZ = -15 + Math.random() * 30;
}
// Impostors sometimes approach a nearby crewmate.
if (bot.isImpostor && bot.cooldown <= 0) {
const targets = bots.filter(b =>
b.alive && !b.isImpostor && b !== bot &&
Math.hypot(b.x - bot.x, b.z - bot.z) < 8
);
if (targets.length && Math.random() < 0.75) {
const victim = targets[Math.floor(Math.random() * targets.length)];
// A short, non-graphic sci-fi tagging animation.
bot.targetX = victim.x;
bot.targetZ = victim.z;
bot.cooldown = 8 + Math.random() * 8;
setTimeout(() => {
if (!playing || !bot.alive || !victim.alive) return;
const d = Math.hypot(bot.x - victim.x, bot.z - victim.z);
if (d < 2.2) {
eliminateBot(victim);
}
}, 1200);
} else {
bot.cooldown = 5;
}
}
const dx = bot.targetX - bot.x;
const dz = bot.targetZ - bot.z;
const length = Math.hypot(dx, dz);
if (length > 0.2) {
const pace = bot.isImpostor ? 2.2 : 1.5;
bot.x += dx / length * pace * dt;
bot.z += dz / length * pace * dt;
}
bot.x = THREE.MathUtils.clamp(bot.x, -23, 23);
bot.z = THREE.MathUtils.clamp(bot.z, -17, 17);
bot.mesh.position.set(bot.x, 0, bot.z);
bot.mesh.rotation.y = Math.sin(time + bot.phase) * 0.12;
}
}
// ---------------- AUTOMATIC REPORTING ----------------
function botReporting(dt) {
for (const item of evidence) {
if (item.reported) continue;
for (const bot of bots) {
if (!bot.alive || bot.isImpostor) continue;
const d = Math.hypot(bot.x - item.x, bot.z - item.z);
if (d < 1.7 && Math.random() < dt * 0.25) {
item.reported = true;
showMeeting(bot.name + " reported " + item.victim + "!");
return;
}
}
}
}
// ---------------- MEETINGS AND VOTING ----------------
function showMeeting(message) {
if (!playing || meetingOpen) return;
playing = false;
meetingOpen = true;
prompt.style.display = "none";
meetingMessage = message;
const panel = document.getElementById("panel");
const overlay = document.getElementById("overlay");
overlay.style.display = "flex";
panel.innerHTML = `
<h1>🚨 EMERGENCY MEETING</h1>
<p>${message}</p>
<p class="small">Who do you think is an impostor?</p>
<div id="votes"></div>
`;
const votes = document.getElementById("votes");
bots.filter(b => b.alive).forEach(bot => {
const button = document.createElement("button");
button.className = "voteButton";
button.textContent = "Vote for " + bot.name;
button.onclick = () => castVote(bot);
votes.appendChild(button);
});
const skip = document.createElement("button");
skip.className = "voteButton";
skip.textContent = "Skip vote";
skip.onclick = () => resumeGame("You skipped the vote.");
votes.appendChild(skip);
}
function castVote(bot) {
const caught = bot.isImpostor;
if (caught) {
impostors = impostors.filter(i => i !== bot);
bot.alive = false;
scene.remove(bot.mesh);
updateCrewCount();
if (impostors.length === 0) {
finishGame(true, "You found every impostor! The spaceship is safe!");
return;
}
resumeGame("Correct! " + bot.name + " was an impostor. One remains.");
} else {
resumeGame("Wrong vote! " + bot.name + " was not an impostor.");
}
}
function resumeGame(message) {
meetingOpen = false;
playing = true;
document.getElementById("overlay").style.display = "none";
document.getElementById("status").textContent = message;
}
function finishGame(won, message) {
playing = false;
gameFinished = true;
meetingOpen = false;
prompt.style.display = "none";
const panel = document.getElementById("panel");
document.getElementById("overlay").style.display = "flex";
panel.innerHTML = `
<h1 style="color:${won ? "#5dffad" : "#ff7373"}">
${won ? "CREW VICTORY!" : "MISSION OVER!"}
</h1>
<p>${message}</p>
<p class="small">Tasks completed: ${tasksDone}/4</p>
<button id="again">PLAY AGAIN</button>
`;
document.getElementById("again").onclick = startGame;
}
function callMeeting() {
if (!playing) return;
showMeeting("You called an emergency meeting!");
}
// ---------------- PLAYER CONTROLS ----------------
const keys = {};
document.addEventListener("keydown", e => {
const k = e.key.toLowerCase();
keys[k] = true;
if (k === "e") interact();
if (k === "r" && nearestEvidence) reportEvidence(nearestEvidence);
if (k === "v") callMeeting();
if (["arrowup","arrowdown","arrowleft","arrowright"].includes(k)) {
e.preventDefault();
}
});
document.addEventListener("keyup", e => {
keys[e.key.toLowerCase()] = false;
});
function bindMove(id, key) {
const button = document.getElementById(id);
button.addEventListener("pointerdown", e => {
e.preventDefault();
keys[key] = true;
});
for (const event of ["pointerup", "pointerleave", "pointercancel"]) {
button.addEventListener(event, () => keys[key] = false);
}
}
bindMove("up", "w");
bindMove("down", "s");
bindMove("left", "a");
bindMove("right", "d");
document.getElementById("reportBtn").onclick = () => {
if (nearestEvidence) reportEvidence(nearestEvidence);
};
document.getElementById("meetingBtn").onclick = callMeeting;
// ---------------- START / RESET ----------------
function startGame() {
// Remove old evidence and markers.
evidence.forEach(item => scene.remove(item.mesh));
evidence = [];
// Remove old bots.
bots.forEach(bot => scene.remove(bot.mesh));
// Reset tasks and characters.
createBots();
createTasks();
playerX = 0;
playerZ = 0;
player.position.set(0, 0, 0);
tasksDone = 0;
meetingOpen = false;
gameFinished = false;
playing = true;
nearestTask = null;
nearestEvidence = null;
updateTasks();
updateCrewCount();
document.getElementById("overlay").style.display = "none";
prompt.style.display = "none";
}
document.getElementById("start").onclick = startGame;
// ---------------- MAIN LOOP ----------------
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.04);
const time = clock.elapsedTime;
if (playing) {
let dx = 0;
let dz = 0;
if (keys["w"] || keys["arrowup"]) dz -= 1;
if (keys["s"] || keys["arrowdown"]) dz += 1;
if (keys["a"] || keys["arrowleft"]) dx -= 1;
if (keys["d"] || keys["arrowright"]) dx += 1;
const length = Math.hypot(dx, dz);
if (length > 0) {
dx /= length;
dz /= length;
}
const moveSpeed = 7;
playerX += dx * moveSpeed * dt;
playerZ += dz * moveSpeed * dt;
playerX = THREE.MathUtils.clamp(playerX, -23, 23);
playerZ = THREE.MathUtils.clamp(playerZ, -17, 17);
player.position.set(playerX, 0, playerZ);
if (length > 0) {
player.rotation.y = Math.atan2(dx, dz);
}
moveBots(dt, time);
botReporting(dt);
checkNearby();
}
// Smooth camera follow.
camera.position.x += (playerX * 0.55 - camera.position.x) *
Math.min(1, dt * 3);
camera.position.z += (playerZ * 0.55 + 30 - camera.position.z) *
Math.min(1, dt * 3);
camera.lookAt(playerX * 0.45, 0, playerZ * 0.35);
// Pulse task markers.
for (const item of consoles) {
if (!item.task.done) {
const pulse = 1 + Math.sin(time * 3) * 0.08;
item.marker.scale.set(pulse, pulse, pulse);
}
}
// Pulse evidence beacons.
for (const item of evidence) {
item.mesh.rotation.y += dt;
const pulse = 1 + Math.sin(time * 4) * 0.12;
item.mesh.scale.set(pulse, pulse, pulse);
}
renderer.render(scene, camera);
}
animate();
// ---------------- RESIZE ----------------
window.addEventListener("resize", () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>Game Source: Space Mystery 3D - Expanded
Creator: AtomicBolt46
Libraries: three
Complexity: complex (929 lines, 21.4 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: space-mystery-3d-expanded-atomicbolt46" to link back to the original. Then publish at arcadelab.ai/publish.