๐ŸŽฎArcadeLab

RIVALS ARENA

by PrismPanda88
2135 lines53.0 KB๐Ÿ› ๏ธ Three.js (3D graphics)
โ–ถ Play

/*
  RIVALS ARENA โ€” INTEGRATED MULTIPLAYER BUILD
  Original fictional arena game.
  Includes:
  - 3D browser arena
  - Online WebSocket multiplayer
  - Registration and login
  - Rooms and team modes
  - Free-for-all and team matches
  - Movement, jumping and sprinting
  - Three fictional energy blasters
  - Ammo, reload and weapon switching
  - Health, respawning and scores
  - Map selection and map voting
  - Match timer and results
  - Chat, friends and player reporting
  - XP, levels, coins and saved statistics
  - Shop, cosmetics and daily rewards
  - Missions, achievements and battle pass
  - Leaderboard and match history
  - Mobile-friendly controls

  Install:
    npm install ws

  Run:
    node server.js

  Open:
    http://localhost:3000

  For internet multiplayer, deploy this server to a host
  supporting Node.js, persistent processes and WebSockets.
*/

"use strict";

const http = require("http");
const WebSocket = require("ws");
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");

const PORT = process.env.PORT || 3000;
const DATA_FILE = path.join(__dirname, "rivals-arena-data.json");
const MATCH_SECONDS = 180;
const MAX_PLAYERS = 8;
const TICK_RATE = 20;

const WEAPONS = {
  pulse: {
    name: "Pulse Blaster",
    damage: 25,
    ammo: 20,
    reload: 1.2,
    cooldown: 350,
    range: 35
  },
  burst: {
    name: "Burst Blaster",
    damage: 12,
    ammo: 36,
    reload: 1.5,
    cooldown: 110,
    range: 28
  },
  nova: {
    name: "Nova Blaster",
    damage: 55,
    ammo: 6,
    reload: 1.8,
    cooldown: 750,
    range: 22
  }
};

const MAPS = [
  "NeonCity",
  "SkyArena",
  "DesertBase",
  "CrystalFort",
  "SpaceStation"
];

const MODES = [
  "Free For All",
  "Team Arena",
  "King Arena",
  "Training"
];

let database = { users: {} };

try {
  if (fs.existsSync(DATA_FILE)) {
    database = JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
    if (!database.users) database.users = {};
  }
} catch {
  database = { users: {} };
}

function saveDatabase() {
  const temp = DATA_FILE + ".tmp";
  fs.writeFileSync(temp, JSON.stringify(database, null, 2));
  fs.renameSync(temp, DATA_FILE);
}

function cleanName(value) {
  return String(value || "")
    .replace(/[^a-zA-Z0-9_ -]/g, "")
    .trim()
    .slice(0, 18);
}

function makeId() {
  return crypto.randomBytes(12).toString("hex");
}

function makeCode() {
  return crypto.randomBytes(3).toString("hex").toUpperCase();
}

function passwordHash(password, salt) {
  return crypto.scryptSync(password, salt, 64).toString("hex");
}

function safeUser(user) {
  return {
    username: user.username,
    level: user.level,
    xp: user.xp,
    coins: user.coins,
    wins: user.wins,
    losses: user.losses,
    eliminations: user.eliminations,
    matches: user.matches,
    equipped: user.equipped,
    inventory: user.inventory,
    friends: user.friends,
    history: user.history.slice(0, 10),
    achievements: user.achievements,
    battlePassXP: user.battlePassXP,
    season: user.season,
    missions: user.missions
  };
}

function getUser(username) {
  return database.users[String(username || "").toLowerCase()];
}

function createUser(username, password) {
  const salt = crypto.randomBytes(16).toString("hex");

  return {
    username,
    salt,
    passwordHash: passwordHash(password, salt),
    level: 1,
    xp: 0,
    coins: 250,
    wins: 0,
    losses: 0,
    eliminations: 0,
    matches: 0,
    equipped: "default",
    inventory: ["default"],
    friends: [],
    history: [],
    achievements: [],
    battlePassXP: 0,
    season: 1,
    lastDaily: "",
    missions: {
      matches: 0,
      eliminations: 0,
      wins: 0
    },
    reports: []
  };
}

const sessions = new Map();
const sockets = new Map();
const rooms = new Map();
const invitations = new Map();
const rateLimits = new Map();

function rateLimit(key, maximum, period) {
  const now = Date.now();
  let entry = rateLimits.get(key);

  if (!entry || now - entry.start > period) {
    entry = { start: now, count: 0 };
  }

  entry.count++;
  rateLimits.set(key, entry);

  return entry.count <= maximum;
}

function send(socket, type, data = {}) {
  if (socket && socket.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify({ type, ...data }));
  }
}

function broadcast(room, type, data = {}) {
  for (const player of room.players.values()) {
    send(player.socket, type, data);
  }
}

function publicPlayer(player) {
  return {
    id: player.id,
    username: player.username,
    x: player.x,
    y: player.y,
    z: player.z,
    yaw: player.yaw,
    health: player.health,
    team: player.team,
    score: player.score,
    weapon: player.weapon,
    alive: player.alive,
    color: player.color
  };
}

function newPlayer(socket, username) {
  return {
    id: makeId(),
    socket,
    username,
    x: Math.random() * 8 - 4,
    y: 1.6,
    z: Math.random() * 8 - 4,
    yaw: 0,
    pitch: 0,
    health: 100,
    team: 0,
    score: 0,
    alive: true,
    weapon: "pulse",
    ammo: WEAPONS.pulse.ammo,
    reloading: false,
    lastShot: 0,
    lastMove: Date.now(),
    lastReload: 0,
    color: ["#5ee7ff", "#ffcc66", "#bd88ff", "#70f0a5"][
      Math.floor(Math.random() * 4)
    ]
  };
}

function createRoom(owner, code) {
  const room = {
    code,
    owner,
    players: new Map(),
    status: "lobby",
    mode: "Free For All",
    map: "NeonCity",
    duration: MATCH_SECONDS,
    timeLeft: MATCH_SECONDS,
    scoreLimit: 20,
    startedAt: 0,
    lastTick: Date.now(),
    votes: {},
    king: null,
    messages: [],
    results: []
  };

  rooms.set(code, room);
  return room;
}

function leaveRoom(player) {
  if (!player || !player.room) return;

  const room = rooms.get(player.room);
  if (!room) {
    player.room = null;
    return;
  }

  room.players.delete(player.id);
  player.room = null;

  broadcast(room, "playerLeft", {
    username: player.username
  });

  if (room.players.size === 0) {
    rooms.delete(room.code);
    return;
  }

  if (room.owner === player.username) {
    room.owner = room.players.values().next().value.username;
  }

  broadcast(room, "roomUpdate", roomInfo(room));
}

function roomInfo(room) {
  return {
    code: room.code,
    owner: room.owner,
    status: room.status,
    mode: room.mode,
    map: room.map,
    duration: room.duration,
    scoreLimit: room.scoreLimit,
    players: [...room.players.values()].map(publicPlayer)
  };
}

function joinRoom(socket, user, code) {
  code = String(code || "").toUpperCase().replace(/[^A-Z0-9]/g, "");

  if (!code) code = makeCode();

  let room = rooms.get(code);
  if (!room) room = createRoom(user.username, code);

  if (room.status !== "lobby" && room.mode !== "Training") {
    send(socket, "error", { message: "This match has already started." });
    return;
  }

  if (room.players.size >= MAX_PLAYERS) {
    send(socket, "error", { message: "This room is full." });
    return;
  }

  const player = newPlayer(socket, user.username);
  player.room = code;

  if (room.mode === "Team Arena") {
    const counts = [0, 0];
    for (const p of room.players.values()) counts[p.team]++;
    player.team = counts[0] <= counts[1] ? 0 : 1;
  }

  room.players.set(player.id, player);
  socket.playerId = player.id;

  send(socket, "joined", {
    self: player.id,
    user: safeUser(user),
    room: roomInfo(room)
  });

  broadcast(room, "playerJoined", {
    player: publicPlayer(player)
  });

  broadcast(room, "roomUpdate", roomInfo(room));
}

function getPlayer(socket) {
  const id = socket.playerId;
  const session = sockets.get(socket);

  if (!session || !id || !session.room) return null;

  const room = rooms.get(session.room);
  if (!room) return null;

  return room.players.get(id) || null;
}

function startMatch(room) {
  if (room.status === "playing") return;

  room.status = "playing";
  room.startedAt = Date.now();
  room.lastTick = Date.now();
  room.timeLeft = room.duration;
  room.votes = {};
  room.king = null;

  for (const player of room.players.values()) {
    player.health = 100;
    player.score = 0;
    player.alive = true;
    player.x = Math.random() * 10 - 5;
    player.z = Math.random() * 10 - 5;
    player.weapon = "pulse";
    player.ammo = WEAPONS.pulse.ammo;
    player.reloading = false;
  }

  broadcast(room, "matchStarted", {
    map: room.map,
    mode: room.mode,
    duration: room.duration,
    players: [...room.players.values()].map(publicPlayer)
  });
}

function finishMatch(room) {
  if (room.status !== "playing") return;

  room.status = "results";

  const ranking = [...room.players.values()]
    .sort((a, b) => b.score - a.score);

  const topScore = ranking[0] ? ranking[0].score : 0;

  const winners = ranking.filter(p => p.score === topScore);

  for (const player of ranking) {
    const user = getUser(player.username);
    if (!user) continue;

    user.matches++;
    user.missions.matches++;

    if (winners.some(w => w.id === player.id)) {
      user.wins++;
      user.missions.wins++;
      user.coins += 100;
      user.xp += 150;
    } else {
      user.losses++;
      user.coins += 35;
      user.xp += 50;
    }

    user.battlePassXP += 50;
    user.history.unshift({
      date: new Date().toISOString(),
      map: room.map,
      mode: room.mode,
      score: player.score,
      result: winners.some(w => w.id === player.id) ? "Win" : "Played"
    });

    user.history = user.history.slice(0, 20);

    while (user.xp >= user.level * 250) {
      user.xp -= user.level * 250;
      user.level++;
      user.coins += 50;
    }
  }

  saveDatabase();

  const results = ranking.map(p => ({
    username: p.username,
    score: p.score,
    team: p.team
  }));

  room.results = results;

  broadcast(room, "matchResults", {
    results,
    winner: winners.map(w => w.username)
  });

  setTimeout(() => {
    if (!rooms.has(room.code)) return;

    room.status = "lobby";
    room.timeLeft = room.duration;
    room.votes = {};

    broadcast(room, "roomUpdate", roomInfo(room));
  }, 10000);
}

function reload(player) {
  const weapon = WEAPONS[player.weapon];

  if (!weapon || player.reloading || player.ammo === weapon.ammo) return;

  player.reloading = true;
  player.lastReload = Date.now();

  send(player.socket, "weaponState", {
    weapon: player.weapon,
    ammo: player.ammo,
    maxAmmo: weapon.ammo,
    reloading: true
  });

  setTimeout(() => {
    const currentRoom = rooms.get(player.room);
    const current = currentRoom && currentRoom.players.get(player.id);

    if (!current || !current.alive) return;

    current.ammo = WEAPONS[current.weapon].ammo;
    current.reloading = false;

    send(current.socket, "weaponState", {
      weapon: current.weapon,
      ammo: current.ammo,
      maxAmmo: WEAPONS[current.weapon].ammo,
      reloading: false
    });
  }, weapon.reload * 1000);
}

function switchWeapon(player, weaponName) {
  if (!WEAPONS[weaponName]) return;

  player.weapon = weaponName;
  player.ammo = WEAPONS[weaponName].ammo;
  player.reloading = false;

  send(player.socket, "weaponState", {
    weapon: weaponName,
    ammo: player.ammo,
    maxAmmo: WEAPONS[weaponName].ammo,
    reloading: false
  });
}

function fire(player, data) {
  const room = rooms.get(player.room);

  if (!room || room.status !== "playing" || !player.alive) return;
  if (player.reloading) return;

  const weapon = WEAPONS[player.weapon];
  if (!weapon || player.ammo <= 0) {
    send(player.socket, "emptyAmmo");
    return;
  }

  const now = Date.now();
  if (now - player.lastShot < weapon.cooldown) return;

  player.lastShot = now;
  player.ammo--;

  const yaw = Number(data.yaw);
  const pitch = Number(data.pitch);

  if (!Number.isFinite(yaw) || !Number.isFinite(pitch)) return;

  const direction = {
    x: -Math.sin(yaw) * Math.cos(pitch),
    y: Math.sin(pitch),
    z: -Math.cos(yaw) * Math.cos(pitch)
  };

  let target = null;
  let nearest = weapon.range;

  for (const other of room.players.values()) {
    if (other.id === player.id || !other.alive) continue;

    if (
      room.mode === "Team Arena" &&
      other.team === player.team
    ) continue;

    const dx = other.x - player.x;
    const dy = other.y - player.y;
    const dz = other.z - player.z;

    const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
    if (distance > nearest || distance < 0.01) continue;

    const dot =
      (dx / distance) * direction.x +
      (dy / distance) * direction.y +
      (dz / distance) * direction.z;

    if (dot > 0.965) {
      nearest = distance;
      target = other;
    }
  }

  if (target) {
    target.health -= weapon.damage;

    send(player.socket, "hitMarker", {
      damage: weapon.damage
    });

    send(target.socket, "hitReceived", {
      health: Math.max(0, target.health)
    });

    if (target.health <= 0) {
      target.alive = false;
      player.score++;

      const user = getUser(player.username);
      if (user) {
        user.eliminations++;
        user.missions.eliminations++;
        user.coins += 10;
        user.xp += 20;
      }

      if (room.mode === "King Arena") {
        room.king = player.username;
      }

      broadcast(room, "elimination", {
        attacker: player.username,
        target: target.username,
        scores: [...room.players.values()].map(p => ({
          username: p.username,
          score: p.score
        }))
      });

      setTimeout(() => {
        const activeRoom = rooms.get(room.code);
        const respawning = activeRoom &&
          activeRoom.players.get(target.id);

        if (!respawning || activeRoom.status !== "playing") return;

        respawning.health = 100;
        respawning.alive = true;
        respawning.x = Math.random() * 10 - 5;
        respawning.z = Math.random() * 10 - 5;

        send(respawning.socket, "respawned", {
          x: respawning.x,
          y: respawning.y,
          z: respawning.z
        });

        broadcast(activeRoom, "playerUpdate", {
          player: publicPlayer(respawning)
        });
      }, 2500);

      saveDatabase();

      if (
        room.mode !== "Training" &&
        player.score >= room.scoreLimit
      ) {
        finishMatch(room);
      }
    }
  }

  send(player.socket, "weaponState", {
    weapon: player.weapon,
    ammo: player.ammo,
    maxAmmo: weapon.ammo,
    reloading: false
  });
}

function handleMessage(socket, message) {
  const session = sockets.get(socket);
  if (!session) return;

  const data = message || {};
  const user = session.username ? getUser(session.username) : null;

  if (!rateLimit(session.username || socket._socket?.remoteAddress || "guest", 100, 1000)) {
    send(socket, "error", { message: "Please slow down." });
    return;
  }

  if (data.type === "register") {
    const username = cleanName(data.username);
    const password = String(data.password || "");

    if (username.length < 3 || password.length < 8) {
      send(socket, "error", {
        message: "Use a username of 3+ characters and a password of 8+ characters."
      });
      return;
    }

    const key = username.toLowerCase();

    if (database.users[key]) {
      send(socket, "error", { message: "Username already exists." });
      return;
    }

    const newUser = createUser(username, password);
    database.users[key] = newUser;
    saveDatabase();

    const token = makeId();
    sessions.set(token, username);
    session.username = username;

    send(socket, "authenticated", {
      token,
      user: safeUser(newUser)
    });
    return;
  }

  if (data.type === "login") {
    const username = cleanName(data.username);
    const password = String(data.password || "");
    const existing = getUser(username);

    if (
      !existing ||
      !crypto.timingSafeEqual(
        Buffer.from(passwordHash(password, existing.salt), "hex"),
        Buffer.from(existing.passwordHash, "hex")
      )
    ) {
      send(socket, "error", { message: "Incorrect username or password." });
      return;
    }

    const token = makeId();
    sessions.set(token, username);
    session.username = username;

    send(socket, "authenticated", {
      token,
      user: safeUser(existing)
    });
    return;
  }

  if (data.type === "resume") {
    const username = sessions.get(String(data.token || ""));
    const existing = username && getUser(username);

    if (!existing) {
      send(socket, "error", { message: "Please log in again." });
      return;
    }

    session.username = username;

    send(socket, "authenticated", {
      token: data.token,
      user: safeUser(existing)
    });
    return;
  }

  if (!user) {
    send(socket, "error", { message: "Please log in first." });
    return;
  }

  if (data.type === "join") {
    const old = session.room && rooms.get(session.room);
    if (old) {
      const oldPlayer = [...old.players.values()]
        .find(p => p.socket === socket);
      if (oldPlayer) leaveRoom(oldPlayer);
    }

    joinRoom(socket, user, data.code);
    return;
  }

  if (data.type === "createRoom") {
    const code = makeCode();
    joinRoom(socket, user, code);
    return;
  }

  if (data.type === "leave") {
    const player = getPlayer(socket);
    if (player) leaveRoom(player);
    send(socket, "left");
    return;
  }

  if (data.type === "start") {
    const player = getPlayer(socket);
    const room = player && rooms.get(player.room);

    if (!room || room.owner !== user.username) return;

    startMatch(room);
    return;
  }

  if (data.type === "settings") {
    const player = getPlayer(socket);
    const room = player && rooms.get(player.room);

    if (!room || room.owner !== user.username || room.status !== "lobby") return;

    if (MODES.includes(data.mode)) room.mode = data.mode;
    if (MAPS.includes(data.map)) room.map = data.map;

    room.duration = Math.max(60, Math.min(600, Number(data.duration) || MATCH_SECONDS));
    room.scoreLimit = Math.max(5, Math.min(100, Number(data.scoreLimit) || 20));

    for (const p of room.players.values()) {
      p.team = room.mode === "Team Arena"
        ? [...room.players.values()].indexOf(p) % 2
        : 0;
    }

    broadcast(room, "roomUpdate", roomInfo(room));
    return;
  }

  if (data.type === "voteMap") {
    const player = getPlayer(socket);
    const room = player && rooms.get(player.room);

    if (!room || room.status !== "lobby" || !MAPS.includes(data.map)) return;

    room.votes[player.username] = data.map;

    const totals = {};
    for (const map of Object.values(room.votes)) {
      totals[map] = (totals[map] || 0) + 1;
    }

    broadcast(room, "mapVotes", { totals });
    return;
  }

  if (data.type === "move") {
    const player = getPlayer(socket);
    const room = player && rooms.get(player.room);

    if (!player || !room || room.status !== "playing" || !player.alive) return;

    const now = Date.now();
    const delta = Math.min((now - player.lastMove) / 1000, 0.1);
    player.lastMove = now;

    const forward = Math.max(-1, Math.min(1, Number(data.forward) || 0));
    const sideways = Math.max(-1, Math.min(1, Number(data.sideways) || 0));
    const sprint = data.sprint === true;

    const yaw = Number(data.yaw);
    if (Number.isFinite(yaw)) player.yaw = yaw;

    const speed = sprint ? 8 : 5;
    const dx = (-Math.sin(player.yaw) * forward +
      Math.cos(player.yaw) * sideways) * speed * delta;

    const dz = (-Math.cos(player.yaw) * forward -
      Math.sin(player.yaw) * sideways) * speed * delta;

    player.x = Math.max(-45, Math.min(45, player.x + dx));
    player.z = Math.max(-45, Math.min(45, player.z + dz));

    if (data.jump === true && player.y <= 1.61) {
      player.y = 2.5;
    } else {
      player.y = Math.max(1.6, player.y - 0.12);
    }

    player.pitch = Math.max(-1.2, Math.min(1.2, Number(data.pitch) || 0));

    broadcast(room, "playerUpdate", {
      player: publicPlayer(player)
    });
    return;
  }

  if (data.type === "shoot") {
    const player = getPlayer(socket);
    if (player) fire(player, data);
    return;
  }

  if (data.type === "reload") {
    const player = getPlayer(socket);
    if (player) reload(player);
    return;
  }

  if (data.type === "switchWeapon") {
    const player = getPlayer(socket);
    if (player) switchWeapon(player, data.weapon);
    return;
  }

  if (data.type === "chat") {
    const player = getPlayer(socket);
    const room = player && rooms.get(player.room);
    const message = String(data.message || "").trim().slice(0, 160);

    if (!room || !message) return;

    const entry = {
      username: user.username,
      message,
      time: Date.now()
    };

    room.messages.push(entry);
    room.messages = room.messages.slice(-50);

    broadcast(room, "chat", entry);
    return;
  }

  if (data.type === "friend") {
    const friendName = cleanName(data.username);
    const friend = getUser(friendName);

    if (!friend || friendName.toLowerCase() === user.username.toLowerCase()) {
      send(socket, "error", { message: "Player not found." });
      return;
    }

    if (!user.friends.includes(friend.username)) {
      user.friends.push(friend.username);
      friend.friends.push(user.username);
      saveDatabase();
    }

    send(socket, "profile", { user: safeUser(user) });
    return;
  }

  if (data.type === "invite") {
    const targetName = cleanName(data.username);
    const targetSocket = [...sockets.entries()]
      .find(([s, info]) =>
        info.username &&
        info.username.toLowerCase() === targetName.toLowerCase()
      );

    if (!targetSocket) {
      send(socket, "error", { message: "Player is not online." });
      return;
    }

    const player = getPlayer(socket);
    if (!player) return;

    invitations.set(targetName.toLowerCase(), {
      code: player.room,
      from: user.username,
      expires: Date.now() + 60000
    });

    send(targetSocket[0], "invitation", {
      from: user.username,
      code: player.room
    });
    return;
  }

  if (data.type === "acceptInvite") {
    const invite = invitations.get(user.username.toLowerCase());

    if (!invite || invite.expires < Date.now()) {
      send(socket, "error", { message: "Invitation expired." });
      return;
    }

    invitations.delete(user.username.toLowerCase());
    joinRoom(socket, user, invite.code);
    return;
  }

  if (data.type === "dailyReward") {
    const today = new Date().toISOString().slice(0, 10);

    if (user.lastDaily === today) {
      send(socket, "error", { message: "Daily reward already collected." });
      return;
    }

    user.lastDaily = today;
    user.coins += 100;
    saveDatabase();

    send(socket, "profile", { user: safeUser(user) });
    send(socket, "notice", { message: "Daily reward: 100 coins!" });
    return;
  }

  if (data.type === "buy") {
    const items = {
      neon: 150,
      galaxy: 300,
      emerald: 450,
      sunset: 600
    };

    const item = String(data.item || "");
    const cost = items[item];

    if (!cost || user.inventory.includes(item)) return;

    if (user.coins < cost) {
      send(socket, "error", { message: "Not enough coins." });
      return;
    }

    user.coins -= cost;
    user.inventory.push(item);
    saveDatabase();

    send(socket, "profile", { user: safeUser(user) });
    return;
  }

  if (data.type === "equip") {
    const item = String(data.item || "");

    if (!user.inventory.includes(item)) return;

    user.equipped = item;
    saveDatabase();

    send(socket, "profile", { user: safeUser(user) });
    return;
  }

  if (data.type === "report") {
    const target = cleanName(data.username);
    const reason = String(data.reason || "Other").slice(0, 100);

    if (
      !target ||
      target.toLowerCase() === user.username.toLowerCase()
    ) return;

    user.reports.push({
      target,
      reason,
      date: new Date().toISOString()
    });

    user.reports = user.reports.slice(-50);
    saveDatabase();

    send(socket, "notice", {
      message: "Report submitted for review."
    });
    return;
  }

  if (data.type === "leaderboard") {
    const leaders = Object.values(database.users)
      .sort((a, b) => b.wins - a.wins)
      .slice(0, 20)
      .map(u => ({
        username: u.username,
        wins: u.wins,
        level: u.level,
        eliminations: u.eliminations
      }));

    send(socket, "leaderboard", { leaders });
    return;
  }

  if (data.type === "profile") {
    send(socket, "profile", { user: safeUser(user) });
    return;
  }
}

function gameTick() {
  const now = Date.now();

  for (const room of rooms.values()) {
    if (room.status !== "playing") continue;

    const elapsed = Math.floor((now - room.startedAt) / 1000);
    room.timeLeft = Math.max(0, room.duration - elapsed);

    if (room.mode === "King Arena" && !room.king) {
      const highest = [...room.players.values()]
        .sort((a, b) => b.score - a.score)[0];

      if (highest && highest.score > 0) {
        room.king = highest.username;
      }
    }

    broadcast(room, "timer", {
      timeLeft: room.timeLeft,
      king: room.king
    });

    if (room.timeLeft <= 0) {
      finishMatch(room);
    }
  }
}

const HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#10152c">
<title>RIVALS ARENA</title>
<style>
*{box-sizing:border-box}
body{margin:0;background:#0b1022;color:#fff;font-family:Arial,sans-serif}
button,input,select{font:inherit}
button{cursor:pointer;border:0;border-radius:9px;padding:11px 16px;background:#7657ff;color:white;font-weight:bold}
button:hover{filter:brightness(1.15)}
button:disabled{opacity:.5;cursor:not-allowed}
input,select{background:#192442;color:white;border:1px solid #35456d;padding:12px;border-radius:8px;width:100%;margin:5px 0}
#auth{max-width:420px;margin:9vh auto;padding:25px;background:#151f3d;border-radius:18px;text-align:center}
#auth h1{font-size:34px;color:#9b8bff}
#app{display:none}
header{background:#121b35;padding:13px 18px;display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
header strong{font-size:21px;color:#a899ff}
.stats{display:flex;gap:14px;flex-wrap:wrap;color:#d8dcff}
nav{display:flex;gap:7px;padding:12px;flex-wrap:wrap;background:#101a32}
nav button{background:#253456}
.page{display:none;padding:18px;max-width:1200px;margin:auto}
.page.active{display:block}
.panel{background:#151f3d;padding:17px;border-radius:14px;margin:12px 0;border:1px solid #29385f}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
.big{font-size:26px;font-weight:bold}
.muted{color:#aab5d4}
#gameCanvas{width:100%;height:65vh;display:block;background:#070b19;border-radius:12px;touch-action:none}
#gameHud{display:none;position:relative}
.hud{position:absolute;top:12px;left:12px;right:12px;pointer-events:none;display:flex;justify-content:space-between;gap:10px;text-shadow:0 2px 5px black}
.hud span{background:#111a30cc;padding:8px;border-radius:8px}
.crosshair{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:26px;pointer-events:none}
#gameButtons{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
#gameButtons button{flex:1}
#messages{height:150px;overflow:auto;background:#0c1429;padding:8px;border-radius:8px}
.item{background:#202e50;padding:13px;border-radius:10px}
#notice{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:#7657ff;padding:12px 20px;border-radius:10px;display:none;z-index:20}
#mobileControls{display:none;gap:8px;justify-content:center;flex-wrap:wrap;margin-top:10px}
#mobileControls button{min-width:65px}
table{width:100%;border-collapse:collapse}
td,th{text-align:left;padding:9px;border-bottom:1px solid #35415e}
@media(max-width:600px){
 header{align-items:flex-start}
 .stats{font-size:12px}
 #gameCanvas{height:55vh}
 #mobileControls{display:flex}
 nav button{padding:9px;font-size:12px}
}
</style>
</head>
<body>

<div id="auth">
  <h1>RIVALS ARENA</h1>
  <p class="muted">Enter the arena. Build your skills. Play together.</p>
  <input id="username" placeholder="Username" maxlength="18">
  <input id="password" type="password" placeholder="Password">
  <button onclick="authenticate('login')">LOGIN</button>
  <button onclick="authenticate('register')">CREATE ACCOUNT</button>
  <p id="authMessage" class="muted"></p>
</div>

<div id="app">
<header>
  <strong>RIVALS ARENA</strong>
  <div class="stats">
    <span id="playerName">Player</span>
    <span>โญ Level <b id="level">1</b></span>
    <span>๐Ÿช™ <b id="coins">0</b></span>
    <span>๐Ÿ† <b id="wins">0</b></span>
  </div>
</header>

<nav>
  <button onclick="showPage('home')">Home</button>
  <button onclick="showPage('play')">Play</button>
  <button onclick="showPage('friends')">Friends</button>
  <button onclick="showPage('profile')">Profile</button>
  <button onclick="showPage('shop')">Shop</button>
  <button onclick="showPage('battlepass')">Battle Pass</button>
  <button onclick="showPage('leaderboard')">Leaderboard</button>
  <button onclick="showPage('settings')">Settings</button>
</nav>

<section id="home" class="page active">
  <div class="panel">
    <h1>Welcome to the Arena</h1>
    <p class="muted">Join a room, invite friends, choose your equipment and compete.</p>
    <button onclick="createRoom()">CREATE MATCH</button>
    <button onclick="showPage('play')">JOIN MATCH</button>
    <button onclick="dailyReward()">DAILY REWARD</button>
  </div>
  <div class="grid">
    <div class="panel"><div class="big" id="homeMatches">0</div><p class="muted">Matches played</p></div>
    <div class="panel"><div class="big" id="homeElims">0</div><p class="muted">Eliminations</p></div>
    <div class="panel"><div class="big" id="homeXP">0</div><p class="muted">Experience</p></div>
  </div>
  <div class="panel">
    <h2>How to play</h2>
    <p>WASD: move ยท Mouse: look ยท Left click: fire ยท Space: jump ยท Shift: sprint ยท R: reload ยท 1, 2, 3: switch blaster.</p>
    <p class="muted">On phones, use the movement and action buttons below the game.</p>
  </div>
</section>

<section id="play" class="page">
  <div class="panel">
    <h2>Find a Match</h2>
    <input id="roomCode" placeholder="Enter room code">
    <button onclick="joinRoom()">JOIN ROOM</button>
    <button onclick="createRoom()">CREATE PRIVATE ROOM</button>
  </div>
  <div id="lobby" class="panel">
    <h2>Lobby</h2>
    <p>Room: <b id="roomName">Not connected</b></p>
    <p>Players: <b id="playerCount">0</b>/8</p>
    <div class="grid">
      <label>Game mode
        <select id="mode">
          <option>Free For All</option>
          <option>Team Arena</option>
          <option>King Arena</option>
          <option>Training</option>
        </select>
      </label>
      <label>Map
        <select id="map">
          <option>NeonCity</option>
          <option>SkyArena</option>
          <option>DesertBase</option>
          <option>CrystalFort</option>
          <option>SpaceStation</option>
        </select>
      </label>
      <label>Match duration
        <select id="duration">
          <option value="120">2 minutes</option>
          <option value="180" selected>3 minutes</option>
          <option value="300">5 minutes</option>
          <option value="600">10 minutes</option>
        </select>
      </label>
      <label>Score limit
        <select id="scoreLimit">
          <option>10</option><option selected>20</option><option>30</option><option>50</option>
        </select>
      </label>
    </div>
    <button onclick="saveSettings()">SAVE SETTINGS</button>
    <button onclick="voteMap()">VOTE FOR MAP</button>
    <button onclick="startMatch()">START MATCH</button>
    <button onclick="leaveRoom()">LEAVE ROOM</button>
    <div id="roomPlayers"></div>
  </div>

  <div id="gameHud" class="panel">
    <div class="hud">
      <span>โค๏ธ <b id="health">100</b></span>
      <span>โฑ <b id="timer">03:00</b></span>
      <span>๐Ÿ† <b id="score">0</b></span>
      <span>๐Ÿ”‹ <b id="ammo">20</b></span>
    </div>
    <canvas id="gameCanvas"></canvas>
    <div class="crosshair">+</div>
    <div id="gameButtons">
      <button onclick="shoot()">FIRE</button>
      <button onclick="reloadWeapon()">RELOAD</button>
      <button onclick="switchWeapon('pulse')">PULSE</button>
      <button onclick="switchWeapon('burst')">BURST</button>
      <button onclick="switchWeapon('nova')">NOVA</button>
      <button onclick="leaveGame()">EXIT GAME</button>
    </div>
    <div id="mobileControls">
      <button onpointerdown="mobileMove('forward',true)" onpointerup="mobileMove('forward',false)">โ–ฒ</button>
      <button onpointerdown="mobileMove('left',true)" onpointerup="mobileMove('left',false)">โ—€</button>
      <button onpointerdown="mobileMove('back',true)" onpointerup="mobileMove('back',false)">โ–ผ</button>
      <button onpointerdown="mobileMove('right',true)" onpointerup="mobileMove('right',false)">โ–ถ</button>
      <button onclick="jump()">JUMP</button>
      <button onclick="shoot()">FIRE</button>
    </div>
    <p id="weaponLabel">Pulse Blaster</p>
  </div>

  <div class="panel">
    <h3>Match Chat</h3>
    <div id="messages"></div>
    <input id="chatInput" placeholder="Write a message">
    <button onclick="sendChat()">SEND</button>
  </div>
</section>

<section id="friends" class="page">
  <div class="panel">
    <h2>Friends</h2>
    <input id="friendName" placeholder="Player username">
    <button onclick="addFriend()">ADD FRIEND</button>
    <div id="friendList"></div>
    <h3>Invite a friend to your room</h3>
    <input id="inviteName" placeholder="Friend username">
    <button onclick="inviteFriend()">INVITE</button>
  </div>
</section>

<section id="profile" class="page">
  <div class="panel">
    <h2>Player Profile</h2>
    <div id="profileDetails"></div>
    <h3>Recent matches</h3>
    <div id="history"></div>
  </div>
</section>

<section id="shop" class="page">
  <div class="panel">
    <h2>Cosmetic Shop</h2>
    <p class="muted">Cosmetic items do not increase damage or give competitive advantages.</p>
    <div class="grid" id="shopItems"></div>
    <h3>Your inventory</h3>
    <div id="inventory"></div>
  </div>
</section>

<section id="battlepass" class="page">
  <div class="panel">
    <h2>Season 1 Battle Pass</h2>
    <p>Earn battle pass XP by playing matches.</p>
    <p>Battle pass XP: <b id="bpXP">0</b></p>
    <div id="bpTiers"></div>
    <p class="muted">This build tracks season progress; premium purchases are not enabled.</p>
  </div>
  <div class="panel">
    <h3>Missions</h3>
    <div id="missions"></div>
  </div>
</section>

<section id="leaderboard" class="page">
  <div class="panel">
    <h2>Leaderboard</h2>
    <button onclick="loadLeaderboard()">REFRESH</button>
    <div id="leaderboardRows"></div>
  </div>
</section>

<section id="settings" class="page">
  <div class="panel">
    <h2>Settings</h2>
    <label>Mouse sensitivity
      <input id="sensitivity" type="range" min="0.2" max="3" step="0.1" value="1">
    </label>
    <label>Sound
      <select id="sound">
        <option>On</option><option>Off</option>
      </select>
    </label>
    <button onclick="saveLocalSettings()">SAVE SETTINGS</button>
    <button onclick="logout()">LOG OUT</button>
  </div>
  <div class="panel">
    <h3>Safety and support</h3>
    <p>Keep chat respectful. Do not share passwords or private information.</p>
    <input id="reportName" placeholder="Player username to report">
    <select id="reportReason">
      <option>Harassment</option>
      <option>Cheating concern</option>
      <option>Inappropriate chat</option>
      <option>Other</option>
    </select>
    <button onclick="reportPlayer()">SUBMIT REPORT</button>
  </div>
</section>

</div>
<div id="notice"></div>

<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<script>
let ws;
let token = localStorage.getItem("ra_token") || "";
let me = null;
let room = null;
let selfId = null;
let players = {};
let keys = {};
let yaw = 0;
let pitch = 0;
let gameRunning = false;
let lastMoveSent = 0;
let healthValue = 100;
let currentWeapon = "pulse";
let ammoValue = 20;
let mobile = {};
let scene, camera, renderer, playerObjects = {};
let moveInterval;
let pointerLocked = false;

const $ = id => document.getElementById(id);

function connect() {
  const protocol = location.protocol === "https:" ? "wss:" : "ws:";
  ws = new WebSocket(protocol + "//" + location.host);

  ws.onopen = () => {
    if (token) send({ type: "resume", token });
  };

  ws.onmessage = event => {
    let data;
    try { data = JSON.parse(event.data); } catch { return; }
    handleServer(data);
  };

  ws.onclose = () => {
    notice("Connection lost. Reconnecting...");
    setTimeout(connect, 2000);
  };
}

function send(data) {
  if (ws && ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify(data));
  }
}

function notice(message) {
  $("notice").textContent = message;
  $("notice").style.display = "block";
  clearTimeout(notice.timer);
  notice.timer = setTimeout(() => {
    $("notice").style.display = "none";
  }, 2500);
}

function authenticate(type) {
  const username = $("username").value.trim();
  const password = $("password").value;

  if (!username || !password) {
    $("authMessage").textContent = "Enter your username and password.";
    return;
  }

  send({ type, username, password });
}

function handleServer(data) {
  if (data.type === "authenticated") {
    token = data.token;
    localStorage.setItem("ra_token", token);
    me = data.user;
    $("auth").style.display = "none";
    $("app").style.display = "block";
    updateProfile(me);
    notice("Welcome to RIVALS ARENA!");
  }

  if (data.type === "error") {
    $("authMessage").textContent = data.message;
    notice(data.message);
  }

  if (data.type === "notice") notice(data.message);

  if (data.type === "profile") updateProfile(data.user);

  if (data.type === "joined") {
    room = data.room;
    selfId = data.self;
    players = {};
    room.players.forEach(p => players[p.id] = p);
    showPage("play");
    updateRoom();
    notice("Joined room " + room.code);
  }

  if (data.type === "roomUpdate") {
    room = data;
    players = {};
    data.players.forEach(p => players[p.id] = p);
    updateRoom();
  }

  if (data.type === "playerJoined") {
    players[data.player.id] = data.player;
    updateRoom();
  }

  if (data.type === "playerLeft") {
    notice(data.username + " left the room.");
  }

  if (data.type === "playerUpdate") {
    players[data.player.id] = data.player;
    updatePlayerObject(data.player);
    updateScores();
  }

  if (data.type === "matchStarted") {
    room.status = "playing";
    $("gameHud").style.display = "block";
    initGame();
    notice("Match started!");
  }

  if (data.type === "timer") {
    const m = Math.floor(data.timeLeft / 60);
    const s = data.timeLeft % 60;
    $("timer").textContent =
      String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
  }

  if (data.type === "weaponState") {
    currentWeapon = data.weapon;
    ammoValue = data.ammo;
    $("ammo").textContent = data.ammo + "/" + data.maxAmmo;
    $("weaponLabel").textContent =
      data.weapon.toUpperCase() + (data.reloading ? " โ€” RELOADING" : "");
  }

  if (data.type === "hitMarker") {
    $("score").textContent = Number($("score").textContent) + 1;
  }

  if (data.type === "hitReceived") {
    healthValue = data.health;
    $("health").textContent = healthValue;
  }

  if (data.type === "respawned") {
    healthValue = 100;
    $("health").textContent = 100;
    if (camera) camera.position.set(data.x, 1.6, data.z);
  }

  if (data.type === "elimination") {
    notice(data.attacker + " scored an elimination!");
    updateScores(data.scores);
  }

  if (data.type === "matchResults") {
    gameRunning = false;
    $("gameHud").style.display = "none";
    destroyGame();
    let text = "MATCH RESULTS\\n";
    data.results.forEach((p, i) => {
      text += (i + 1) + ". " + p.username + " โ€” " + p.score + "\\n";
    });
    alert(text);
    send({ type: "profile" });
  }

  if (data.type === "left") {
    room = null;
    $("gameHud").style.display = "none";
    destroyGame();
    updateRoom();
  }

  if (data.type === "chat") {
    const line = document.createElement("p");
    line.textContent = data.username + ": " + data.message;
    $("messages").appendChild(line);
    $("messages").scrollTop = $("messages").scrollHeight;
  }

  if (data.type === "leaderboard") {
    $("leaderboardRows").innerHTML =
      "<table><tr><th>Player</th><th>Level</th><th>Wins</th><th>Eliminations</th></tr>" +
      data.leaders.map(p =>
        "<tr><td>" + escapeHTML(p.username) + "</td><td>" +
        p.level + "</td><td>" + p.wins + "</td><td>" +
        p.eliminations + "</td></tr>"
      ).join("") + "</table>";
  }

  if (data.type === "invitation") {
    if (confirm(data.from + " invited you to room " + data.code + ". Join?")) {
      send({ type: "acceptInvite" });
    }
  }

  if (data.type === "mapVotes") {
    notice("Map votes updated.");
  }
}

function escapeHTML(text) {
  return String(text).replace(/[&<>"']/g, c => ({
    "&": "&amp;", "<": "&lt;", ">": "&gt;",
    '"': "&quot;", "'": "&#39;"
  }[c]));
}

function updateProfile(user) {
  if (!user) return;
  me = user;
  $("playerName").textContent = user.username;
  $("level").textContent = user.level;
  $("coins").textContent = user.coins;
  $("wins").textContent = user.wins;
  $("homeMatches").textContent = user.matches;
  $("homeElims").textContent = user.eliminations;
  $("homeXP").textContent = user.xp;

  $("profileDetails").innerHTML =
    "<p>Username: " + escapeHTML(user.username) + "</p>" +
    "<p>Level: " + user.level + "</p>" +
    "<p>XP: " + user.xp + "</p>" +
    "<p>Wins: " + user.wins + "</p>" +
    "<p>Losses: " + user.losses + "</p>" +
    "<p>Eliminations: " + user.eliminations + "</p>" +
    "<p>Matches: " + user.matches + "</p>";

  $("friendList").innerHTML = (user.friends || [])
    .map(n => "<p>" + escapeHTML(n) + "</p>").join("") || "No friends yet.";

  $("history").innerHTML = (user.history || []).map(h =>
    "<p>" + escapeHTML(h.map) + " โ€” " +
    escapeHTML(h.mode) + " โ€” Score " + h.score +
    " โ€” " + escapeHTML(h.result) + "</p>"
  ).join("") || "No matches yet.";

  $("inventory").innerHTML = (user.inventory || []).map(item =>
    "<button onclick=\\"equipItem('" + escapeHTML(item) + "')\\">EQUIP " +
    escapeHTML(item) + "</button>"
  ).join(" ");

  $("bpXP").textContent = user.battlePassXP || 0;

  const tier = Math.floor((user.battlePassXP || 0) / 100);
  $("bpTiers").innerHTML = Array.from({length: 10}, (_, i) =>
    "<div class='item'>Tier " + (i + 1) + " โ€” " +
    (tier >= i + 1 ? "Unlocked" : "Locked") + "</div>"
  ).join("");

  $("missions").innerHTML =
    "<p>Play matches: " + user.missions.matches + "/5</p>" +
    "<p>Eliminations: " + user.missions.eliminations + "/10</p>" +
    "<p>Wins: " + user.missions.wins + "/3</p>";

  renderShop();
}

function renderShop() {
  const items = [
    ["neon", 150, "Neon Glow"],
    ["galaxy", 300, "Galaxy"],
    ["emerald", 450, "Emerald"],
    ["sunset", 600, "Sunset"]
  ];

  $("shopItems").innerHTML = items.map(item =>
    "<div class='item'><h3>" + item[2] + "</h3><p>๐Ÿช™ " +
    item[1] + "</p><button onclick=\\"buyItem('" +
    item[0] + "')\\">BUY</button></div>"
  ).join("");
}

function showPage(name) {
  document.querySelectorAll(".page").forEach(p => p.classList.remove("active"));
  $(name).classList.add("active");
}

function createRoom() {
  send({ type: "createRoom" });
}

function joinRoom() {
  send({ type: "join", code: $("roomCode").value });
}

function leaveRoom() {
  send({ type: "leave" });
}

function leaveGame() {
  leaveRoom();
}

function startMatch() {
  send({ type: "start" });
}

function saveSettings() {
  send({
    type: "settings",
    mode: $("mode").value,
    map: $("map").value,
    duration: Number($("duration").value),
    scoreLimit: Number($("scoreLimit").value)
  });
}

function voteMap() {
  send({ type: "voteMap", map: $("map").value });
}

function updateRoom() {
  if (!room) {
    $("roomName").textContent = "Not connected";
    $("playerCount").textContent = "0";
    $("roomPlayers").innerHTML = "";
    return;
  }

  $("roomName").textContent = room.code;
  $("roomCode").value = room.code;
  $("playerCount").textContent = room.players.length;

  $("mode").value = room.mode;
  $("map").value = room.map;
  $("duration").value = room.duration;
  $("scoreLimit").value = room.scoreLimit;

  $("roomPlayers").innerHTML = "<h3>Players</h3>" +
    room.players.map(p =>
      "<p>" + escapeHTML(p.username) + " โ€” " +
      (p.team === 0 ? "Blue team" : "Orange team") +
      (p.username === room.owner ? " โ€” Host" : "") + "</p>"
    ).join("");
}

function updateScores(scores) {
  if (!scores) return;
  const meScore = scores.find(s => s.username === me.username);
  if (meScore) $("score").textContent = meScore.score;
}

function dailyReward() {
  send({ type: "dailyReward" });
}

function addFriend() {
  send({ type: "friend", username: $("friendName").value });
}

function inviteFriend() {
  send({ type: "invite", username: $("inviteName").value });
}

function buyItem(item) {
  send({ type: "buy", item });
}

function equipItem(item) {
  send({ type: "equip", item });
}

function loadLeaderboard() {
  send({ type: "leaderboard" });
}

function sendChat() {
  const message = $("chatInput").value.trim();
  if (!message) return;
  send({ type: "chat", message });
  $("chatInput").value = "";
}

function reportPlayer() {
  send({
    type: "report",
    username: $("reportName").value,
    reason: $("reportReason").value
  });
}

function saveLocalSettings() {
  localStorage.setItem("ra_sensitivity", $("sensitivity").value);
  localStorage.setItem("ra_sound", $("sound").value);
  notice("Settings saved.");
}

function logout() {
  localStorage.removeItem("ra_token");
  token = "";
  location.reload();
}

function initGame() {
  if (gameRunning) return;

  gameRunning = true;
  const canvas = $("gameCanvas");

  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x111a35);
  scene.fog = new THREE.Fog(0x111a35, 30, 100);

  camera = new THREE.PerspectiveCamera(
    75, canvas.clientWidth / canvas.clientHeight, 0.1, 150
  );
  camera.position.set(0, 1.6, 5);
  camera.rotation.order = "YXZ";

  renderer = new THREE.WebGLRenderer({
    canvas,
    antialias: true
  });

  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);

  const ambient = new THREE.HemisphereLight(0xb9d5ff, 0x26314e, 2);
  scene.add(ambient);

  const sun = new THREE.DirectionalLight(0xffffff, 2);
  sun.position.set(10, 20, 10);
  scene.add(sun);

  const floor = new THREE.Mesh(
    new THREE.BoxGeometry(100, 1, 100),
    new THREE.MeshStandardMaterial({
      color: 0x273455,
      roughness: 0.85
    })
  );
  floor.position.y = -0.55;
  scene.add(floor);

  const grid = new THREE.GridHelper(100, 50, 0x6d70c7, 0x36466c);
  grid.position.y = -0.03;
  scene.add(grid);

  const colors = [0x4b65a0, 0x7454b8, 0x287b86, 0x8c6944];

  for (let i = 0; i < 24; i++) {
    const width = 2 + Math.random() * 4;
    const height = 2 + Math.random() * 7;
    const depth = 2 + Math.random() * 4;

    const obstacle = new THREE.Mesh(
      new THREE.BoxGeometry(width, height, depth),
      new THREE.MeshStandardMaterial({
        color: colors[i % colors.length]
      })
    );

    obstacle.position.set(
      Math.random() * 70 - 35,
      height / 2,
      Math.random() * 70 - 35
    );

    scene.add(obstacle);
  }

  const ring = new THREE.Mesh(
    new THREE.TorusGeometry(5, 0.15, 8, 48),
    new THREE.MeshStandardMaterial({
      color: 0x8e7aff,
      emissive: 0x3520a0
    })
  );

  ring.rotation.x = Math.PI / 2;
  ring.position.y = 0.2;
  scene.add(ring);

  canvas.addEventListener("click", () => {
    if (document.pointerLockElement !== canvas) {
      canvas.requestPointerLock?.();
    }
  });

  document.addEventListener("pointerlockchange", () => {
    pointerLocked = document.pointerLockElement === canvas;
  });

  document.addEventListener("mousemove", mouseLook);

  for (const player of Object.values(players)) {
    updatePlayerObject(player);
  }

  animate();
  startMovement();
}

function mouseLook(event) {
  if (!gameRunning || !pointerLocked) return;

  const sensitivity = Number(localStorage.getItem("ra_sensitivity") || 1);
  yaw -= event.movementX * 0.002 * sensitivity;
  pitch -= event.movementY * 0.002 * sensitivity;
  pitch = Math.max(-1.2, Math.min(1.2, pitch));

  camera.rotation.set(pitch, yaw, 0);
}

function updatePlayerObject(player) {
  if (!scene || player.id === selfId) return;

  let object = playerObjects[player.id];

  if (!object) {
    const geometry = new THREE.CapsuleGeometry(0.45, 0.9, 4, 8);
    const material = new THREE.MeshStandardMaterial({
      color: player.team === 0 ? 0x54caff : 0xffad5a,
      emissive: player.team === 0 ? 0x103b55 : 0x552b10
    });

    object = new THREE.Mesh(geometry, material);
    scene.add(object);
    playerObjects[player.id] = object;
  }

  object.position.set(player.x, player.y - 0.5, player.z);
  object.rotation.y = player.yaw;
  object.visible = player.alive;
}

function animate() {
  if (!gameRunning || !renderer || !scene || !camera) return;

  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

function startMovement() {
  clearInterval(moveInterval);

  moveInterval = setInterval(() => {
    if (!gameRunning) return;

    let forward = 0;
    let sideways = 0;

    if (keys.KeyW || mobile.forward) forward++;
    if (keys.KeyS || mobile.back) forward--;
    if (keys.KeyD || mobile.right) sideways++;
    if (keys.KeyA || mobile.left) sideways--;

    if (forward || sideways) {
      send({
        type: "move",
        forward,
        sideways,
        sprint: !!keys.ShiftLeft,
        yaw,
        pitch
      });

      const speed = keys.ShiftLeft ? 0.14 : 0.09;

      camera.position.x +=
        (-Math.sin(yaw) * forward + Math.cos(yaw) * sideways) * speed;

      camera.position.z +=
        (-Math.cos(yaw) * forward - Math.sin(yaw) * sideways) * speed;
    }
  }, 50);
}

function shoot() {
  if (!gameRunning) return;
  send({ type: "shoot", yaw, pitch });
}

function reloadWeapon() {
  send({ type: "reload" });
}

function switchWeapon(weapon) {
  send({ type: "switchWeapon", weapon });
}

function jump() {
  send({
    type: "move",
    forward: 0,
    sideways: 0,
    jump: true,
    yaw,
    pitch
  });

  if (camera) camera.position.y = 2.3;

  setTimeout(() => {
    if (camera && gameRunning) camera.position.y = 1.6;
  }, 350);
}

function mobileMove(direction, active) {
  mobile[direction] = active;
}

function destroyGame() {
  gameRunning = false;
  clearInterval(moveInterval);

  document.removeEventListener("mousemove", mouseLook);
  document.exitPointerLock?.();

  if (renderer) {
    renderer.dispose();
    renderer = null;
  }

  scene = null;
  camera = null;
  playerObjects = {};
}

document.addEventListener("keydown", event => {
  keys[event.code] = true;

  if (event.code === "Space") {
    event.preventDefault();
    jump();
  }

  if (event.code === "KeyR") reloadWeapon();
  if (event.code === "Digit1") switchWeapon("pulse");
  if (event.code === "Digit2") switchWeapon("burst");
  if (event.code === "Digit3") switchWeapon("nova");
});

document.addEventListener("keyup", event => {
  keys[event.code] = false;
});

document.addEventListener("mousedown", event => {
  if (event.button === 0 && pointerLocked) shoot();
});

window.addEventListener("resize", () => {
  if (!camera || !renderer) return;

  const canvas = $("gameCanvas");
  camera.aspect = canvas.clientWidth / canvas.clientHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
});

$("chatInput").addEventListener("keydown", event => {
  if (event.key === "Enter") sendChat();
});

connect();
</script>
</body>
</html>`;

const server = http.createServer((req, res) => {
  if (req.url === "/health") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({
      status: "online",
      game: "RIVALS ARENA",
      rooms: rooms.size,
      players: [...rooms.values()].reduce(
        (sum, room) => sum + room.players.size, 0
      )
    }));
    return;
  }

  if (req.url === "/") {
    res.writeHead(200, {
      "Content-Type": "text/html; charset=utf-8",
      "Cache-Control": "no-store"
    });
    res.end(HTML);
    return;
  }

  res.writeHead(404, { "Content-Type": "text/plain" });
  res.end("Not found");
});

const wss = new WebSocket.Server({
  server,
  maxPayload: 16 * 1024
});

wss.on("connection", (socket, request) => {
  const session = {
    username: null,
    room: null
  };

  sockets.set(socket, session);

  socket.on("message", raw => {
    let data;

    try {
      data = JSON.parse(raw.toString());
    } catch {
      send(socket, "error", { message: "Invalid message." });
      return;
    }

    try {
      handleMessage(socket, data);
    } catch (error) {
      console.error("Message error:", error.message);
      send(socket, "error", { message: "Could not process that action." });
    }

    const player = getPlayer(socket);
    if (player) session.room = player.room;
  });

  socket.on("close", () => {
    const player = getPlayer(socket);
    if (player) leaveRoom(player);
    sockets.delete(socket);
  });

  socket.on("error", () => {
    const player = getPlayer(socket);
    if (player) leaveRoom(player);
    sockets.delete(socket);
  });
});

setInterval(gameTick, 1000 / TICK_RATE);

server.listen(PORT, "0.0.0.0", () => {
  console.log("======================================");
  console.log("       RIVALS ARENA SERVER");
  console.log("======================================");
  console.log("Website: http://localhost:" + PORT);
  console.log("Health:  http://localhost:" + PORT + "/health");
  console.log("WebSocket multiplayer: enabled");
  console.log("======================================");
});

Game Source: RIVALS ARENA

Creator: PrismPanda88

Libraries: three

Complexity: complex (2135 lines, 53.0 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: rivals-arena-prismpanda88" to link back to the original. Then publish at arcadelab.ai/publish.