Hitbox Room Commander

Hitbox Room Commander – full control over game settings via chat commands.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Hitbox Room Commander
// @namespace    http://tampermonkey.net/
// @version      3.9
// @description  Hitbox Room Commander – full control over game settings via chat commands.
// @author       Mr-FaZ3a
// @match        https://heav.io/game.html
// @match        https://hitbox.io/game.html
// @match        https://heav.io/game2.html
// @match        https://hitbox.io/game2.html
// @match        https://hitbox.io/game-beta.html
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  class HitboxCommander {
    constructor() {
      this.ws = null;
      this.myid = -1;
      this.hostId = -2;
      this.gameSettings = null;        // current live settings
      this.roomDefaults = null;        // original room defaults (never changes)
      this.nativeSend = window.WebSocket.prototype.send;
      this.users = {};
      this.myName = '';
      this.gameSettingsObject = null;

      this.settingsMap = this.buildSettingsMap();
      this.nameToAbbr = {};
      this.abbrToName = new Map();
      this.categoryNames = ['bat', 'rk', 'push', 'eg', 'dash', 'others', 'unknown'];
      this.categoryOrder = ['Bat', 'RK', 'Push', 'EG', 'Dash', 'Others', 'Unknown'];
      this.buildMaps();

      this.chatHidden = localStorage.getItem('chatHidden') === 'true';
      this.tempChatVisible = false;
      this.chatMessages = [];
      this.chatDialog = null;
      this.userScrolled = false;

      this.init();
      this.initChatToggle();
      this.createChatDialog();
    }

    buildSettingsMap() {
      return [
        // Bat
        { name: 'bbRange', abbr: 'st', category: 'Bat' },
        { name: 'bbPower', abbr: 'it', category: 'Bat' },
        { name: 'bbHoldAmmoCost', abbr: 'rt', category: 'Bat' },
        { name: 'bbFireFramesLength', abbr: 'at', category: 'Bat' },
        { name: 'bbHideAfterFireFrames', abbr: 'lt', category: 'Bat' },
        { name: 'bbResetOn', abbr: 'ut', category: 'Bat' },
        { name: 'bbEnabled', abbr: 'ct', category: 'Bat' },
        { name: 'bbHoldUntil', abbr: 'et', category: 'Bat' },
        { name: 'bbFireOn', abbr: 'nt', category: 'Bat' },
        { name: 'bbAngleVariance', abbr: 'ht', category: 'Bat' },
        // RK
        { name: 'rkAmmoNeeded', abbr: 'kt', category: 'RK' },
        { name: 'rkVelocity', abbr: 'St', category: 'RK' },
        { name: 'rkAimRate', abbr: 'Nt', category: 'RK' },
        { name: 'rkBulletRound', abbr: 'Mt', category: 'RK' },
        { name: 'rkBulletRadius', abbr: 'Ct', category: 'RK' },
        { name: 'rkBulletAge', abbr: 'Tt', category: 'RK' },
        { name: 'rkBulletTurnRate', abbr: 'xt', category: 'RK' },
        { name: 'rkBulletGravityScale', abbr: '_t', category: 'RK' },
        { name: 'rkBulletBounces', abbr: 'Pt', category: 'RK' },
        { name: 'rkBulletRestitution', abbr: 'Et', category: 'RK' },
        { name: 'rkBulletBounceCountLimit', abbr: 'Bt', category: 'RK' },
        { name: 'rkSelfBatScale', abbr: 'It', category: 'RK' },
        { name: 'rkExplodeRadius', abbr: 'Ft', category: 'RK' },
        { name: 'rkExplodePush', abbr: 'At', category: 'RK' },
        // Push
        { name: 'pushPlayers', abbr: 'Y', category: 'Push' },
        { name: 'pushRange', abbr: 'q', category: 'Push' },
        { name: 'pushDelay', abbr: 'V', category: 'Push' },
        { name: 'pushMinAmmoToStartPush', abbr: 'X', category: 'Push' },
        { name: 'pushAmmoSpendIfPushing', abbr: 'Z', category: 'Push' },
        { name: 'pushAmmoSpendAtFirstPushFrame', abbr: '$', category: 'Push' },
        // EG
        { name: 'egEnabled', abbr: 'vt', category: 'EG' },
        { name: 'egSize', abbr: 'Rt', category: 'EG' },
        { name: 'egAge', abbr: 'Dt', category: 'EG' },
        { name: 'egGravityScale', abbr: 'Lt', category: 'EG' },
        { name: 'egRestitution', abbr: 'Ut', category: 'EG' },
        { name: 'egStartSpin', abbr: 'Wt', category: 'EG' },
        { name: 'egMaxThrowPower', abbr: 'Jt', category: 'EG' },
        { name: 'egAimRate', abbr: 'qt', category: 'EG' },
        { name: 'egAmmoNeeded', abbr: 'Gt', category: 'EG' },
        { name: 'egDelay1', abbr: 'Ht', category: 'EG' },
        { name: 'egDelay2', abbr: 'zt', category: 'EG' },
        { name: 'egDelayBeforeAmmoUse', abbr: 'Yt', category: 'EG' },
        { name: 'egShape', abbr: 'Vt', category: 'EG' },
        // Dash
        { name: 'dashInitDelay', abbr: 'dt', category: 'Dash' },
        { name: 'dashActiveLength', abbr: 'wt', category: 'Dash' },
        { name: 'dashCooldownAfterLength', abbr: 'ft', category: 'Dash' },
        { name: 'dashBoostSpeed', abbr: 'gt', category: 'Dash' },
        { name: 'dashAmmoCost', abbr: 'yt', category: 'Dash' },
        { name: 'dashEnabled', abbr: 'bt', category: 'Dash' },
        // Others
        { name: 'timeScale', abbr: 'J', category: 'Others' },
        { name: 'gravityOffset', abbr: 'G', category: 'Others' },
        { name: 'gameMode', abbr: 'H', category: 'Others' },
        { name: 'maxStamina', abbr: 'K', category: 'Others' },
        { name: 'parachuteEnabled', abbr: 'Ot', category: 'Others' },
        { name: 'playerSize', abbr: 'Kt', category: 'Others' },
        { name: 'objectiveLimit', abbr: 'Zt', category: 'Others' },
        { name: 'pushAmmoReplenish', abbr: 'tt', category: 'Others' },
        // Unknown
        { name: 'ht', abbr: 'ht', category: 'Unknown' },
        { name: 'et', abbr: 'et', category: 'Unknown' },
        { name: 'nt', abbr: 'nt', category: 'Unknown' },
        { name: 'ot', abbr: 'ot', category: 'Unknown' },
        { name: 'jt', abbr: 'jt', category: 'Unknown' },
        { name: 'Qt', abbr: 'Qt', category: 'Unknown' },
        { name: '$t', abbr: '$t', category: 'Unknown' },
        { name: 'ti', abbr: 'ti', category: 'Unknown' },
        { name: 'ii', abbr: 'ii', category: 'Unknown' },
        { name: 'si', abbr: 'si', category: 'Unknown' },
        { name: 'hi', abbr: 'hi', category: 'Unknown' },
        { name: 'ei', abbr: 'ei', category: 'Unknown' },
        { name: 'ni', abbr: 'ni', category: 'Unknown' },
        { name: 'oi', abbr: 'oi', category: 'Unknown' },
        { name: 'ri', abbr: 'ri', category: 'Unknown' },
        { name: 'Xt', abbr: 'Xt', category: 'Unknown' },
        { name: 'gsFightersCollide', abbr: 'gsFightersCollide', category: 'Unknown' },
        { name: 'recordMode', abbr: 'recordMode', category: 'Unknown' },
      ];
    }

    buildMaps() {
      this.settingsMap.forEach(item => {
        this.nameToAbbr[item.name.toLowerCase()] = item.abbr;
        this.abbrToName.set(item.abbr, item.name);
      });
    }

    // Get a setting value: current if exists, else default (roomDefaults)
    getSettingValue(abbr) {
      if (this.gameSettings && this.gameSettings[abbr] !== undefined) {
        return this.gameSettings[abbr];
      }
      if (this.roomDefaults && this.roomDefaults[abbr] !== undefined) {
        return this.roomDefaults[abbr];
      }
      return undefined;
    }

    init() {
      this.hookWebSocket();

      const findSettings = () => {
        if (window.multiplayerSession) {
          for (let key in window.multiplayerSession) {
            let obj = window.multiplayerSession[key];
            if (obj && obj.$L && typeof obj.$L === 'object') {
              this.gameSettings = obj.$L;
              this.gameSettingsObject = obj.$L;
              if (!this.roomDefaults) {
                this.roomDefaults = JSON.parse(JSON.stringify(this.gameSettings));
                //console.log('[Commander] Room defaults captured:', this.roomDefaults);
              }
              break;
            }
          }
        }
        if (!this.myName) {
          try {
            const localName = localStorage.getItem('playerName');
            if (localName) this.myName = localName;
          } catch (e) { }
        }
      };
      findSettings();
      const interval = setInterval(() => {
        if (this.gameSettings) {
          clearInterval(interval);
        } else {
          findSettings();
        }
      }, 500);
    }

    // Re‑apply current team assignments to all players
    restoreTeams() {
      for (let id in this.users) {
        let user = this.users[id];
        if (user && user.team !== undefined && user.team !== -1) {
          this.ws.send(`42[1,[47,{"i":${user.id},"t":${user.team}}]]`);
        }
      }
    }

    hookWebSocket() {
      const self = this;
      window.WebSocket.prototype.send = function (args) {
        if (this.url && this.url.includes('/socket.io/')) {
          if (!self.ws || self.ws !== this) {
            self.ws = this;
            this.addEventListener('message', (event) => self.processIncoming(event));
            this.addEventListener('close', () => {
              if (self.ws === this) {
                self.myid = -1;
                self.hostId = -2;
                self.ws = null;
                self.clearMessages();
              }
            });
          }
          try {
            if (typeof args === 'string' && args.startsWith('42[1,[')) {
              let packet = JSON.parse(args.slice(5, -1));
              if (packet[0] === 28) {
                let msg = packet[1].trim();
                if (msg.startsWith('!')) {
                  if (self.processCommand(null, msg, true)) {
                    return;
                  }
                } else {
                  let name = self.myName || 'You';
                  let user = self.users[self.myid];
                  if (user) name = user.name;
                  let nameColor = user ? self.getFontColor(user) : '#FFFFFF';
                  let borderColor = user ? self.getBorderColor(user) : '#FFFFFF';
                  let nameSpan = `<span style="color:${nameColor}; text-shadow:0.5px 0 0 ${borderColor}, -0.5px 0 0 ${borderColor}, 0 0.5px 0 ${borderColor}, 0 -0.5px 0 ${borderColor};">${self.escapeHtml(name)}</span>`;
                  let html = `${nameSpan}: <span>${self.escapeHtml(msg)}</span>`;
                  self.addMessage(html, true);
                }
              }
            }
          } catch (e) { }
        }
        return self.nativeSend.call(this, args);
      };
    }

    processIncoming(event) {
      if (typeof event.data !== 'string' || !event.data.startsWith('42[')) return;
      try {
        let packet = JSON.parse(event.data.slice(2));
        let type = packet[0];
        //console.log(`[Commander] Packet type ${type}`, packet);

        if (type === 7) {
          //console.log('[Commander] 🔵 FULL PLAYER LIST RECEIVED');
          this.clearMessages();
          this.myid = packet[1][0];
          this.hostId = packet[1][1];
          this.users = {};
          for (let p of packet[1][3]) {
            let userId = p[4];
            let customColor = p[7] ? (p[7][1] || p[7][0]) : 0xFFFFFF;
            let team = p[2];
            this.users[userId] = { id: userId, name: p[0], customColor: customColor, team: team };
            if (userId === this.myid) this.myName = p[0];
            //console.log(`[Commander]   Player ${p[0]} (ID ${userId}) team ${team} color 0x${customColor.toString(16)}`);
          }
          // Do NOT reset roomDefaults here
        } else if (type === 8) {
          let userId = packet[1][4];
          let userName = packet[1][0];
          let customColor = packet[7] ? (packet[7][1] || packet[7][0]) : 0xFFFFFF;
          let team = packet[1][2];
          this.users[userId] = { id: userId, name: userName, customColor: customColor, team: team };
          //console.log(`[Commander] Player joined: ${userName} (team ${team})`);
          let html = `<span style="color:#AAAAAA">${this.escapeHtml(userName)} joined the game</span>`;
          this.addMessage(html, false);
        } else if (type === 9) {
          let userId = packet[1];
          let userName = this.users[userId]?.name || 'Unknown';
          this.hostId = packet[2];
          delete this.users[userId];
          //console.log(`[Commander] Player left: ${userName}`);
          let html = `<span style="color:#AAAAAA">${this.escapeHtml(userName)} left the game</span>`;
          this.addMessage(html, false);
        } else if (type === 25) {
          let userId = packet[1];
          let newTeam = packet[2];
          if (this.users[userId]) {
            let oldTeam = this.users[userId].team;
            this.users[userId].team = newTeam;
            //console.log(`[Commander] 🔄 Team change: ${this.users[userId].name} from ${oldTeam} to ${newTeam}`);
          }
        } else if (type === 32) {
          let data = packet[1];
          let kickedUserId = data.id;
          let userName = this.users[kickedUserId]?.name || 'Unknown';
          let action = (data.ban === 1) ? 'banned' : 'kicked';
          //console.log(`[Commander] Player ${userName} ${action}`);
          let html = `<span style="color:#AAAAAA">${this.escapeHtml(userName)} was ${action}</span>`;
          this.addMessage(html, false);
        } else if (type === 42) {
          let mapId = packet[1];
          //console.log(`[Commander] Map changed to ${mapId}`);
          let html = `<span style="color:#AAAAAA">Game starting on new map (ID: ${mapId})</span>`;
          this.addMessage(html, false);
        } else if (type === 43) {
          //console.log(`[Commander] Game start`);
          let html = `<span style="color:#AAAAAA">Game has started!</span>`;
          this.addMessage(html, false);
        } else if (type === 45) {
          this.hostId = packet[1];
          //console.log(`[Commander] New host ID: ${this.hostId}`);
        } else if (type === 41) {
          let userId = packet[1];
          let newColor = packet[2]["1"];
          if (this.users[userId]) {
            let oldColor = this.users[userId].customColor;
            this.users[userId].customColor = newColor;
            //console.log(`[Commander] 🎨 Color change: ${this.users[userId].name} from 0x${oldColor.toString(16)} to 0x${newColor.toString(16)}`);
          }
        } else if (type === 63) {
          this.gameSettings = packet[1];
          this.gameSettingsObject = packet[1];
          if (!this.roomDefaults) {
            this.roomDefaults = JSON.parse(JSON.stringify(this.gameSettings));
            //console.log('[Commander] Room defaults captured from type 63:', this.roomDefaults);
          }
          if (this.gameSettings && this.gameSettings.H !== undefined) {
            //console.log(`[Commander] ⚙️ Game mode changed to: ${this.gameSettings.H}`);
          }
        } else if (type === 74) {
          let teamsEnabled = packet[1];
          let assignments = packet[2] || [];
          //console.log(`[Commander] 🏁 Team mode toggle: enabled=${teamsEnabled}, assignments=`, assignments);
          for (let assignment of assignments) {
            let playerId = assignment[0];
            let team = assignment[1];
            if (this.users[playerId]) {
              let oldTeam = this.users[playerId].team;
              this.users[playerId].team = team;
              //console.log(`[Commander]   Player ${this.users[playerId].name} team ${oldTeam} -> ${team}`);
            }
          }
        }

        if (type === 29) {
          let userId = packet[1];
          if (userId === this.myid) return;
          let msg = packet[2].trim();
          let user = this.users[userId];
          if (!user) return;
          let name = user.name;
          let nameColor = this.getFontColor(user);
          let borderColor = this.getBorderColor(user);
          let nameSpan = `<span style="color:${nameColor}; text-shadow:0.5px 0 0 ${borderColor}, -0.5px 0 0 ${borderColor}, 0 0.5px 0 ${borderColor}, 0 -0.5px 0 ${borderColor};">${this.escapeHtml(name)}</span>`;
          let html = `${nameSpan}: <span>${this.escapeHtml(msg)}</span>`;
          this.addMessage(html, false);
        }
      } catch (e) {
        console.error('[Commander] Error processing packet', e);
      }
    }

    getFontColor(user) {
      if (user.team === 0) return '#888888';
      if (user.team === 1) return this.rgbNumberToHex(user.customColor);
      let baseColor = (user.team === 2) ? '#FF0000' : '#0000FF';
      let luminance = this.getLuminance(user.customColor);
      let factor = Math.max(0.2, luminance);
      let baseR = parseInt(baseColor.slice(1, 3), 16);
      let baseG = parseInt(baseColor.slice(3, 5), 16);
      let baseB = parseInt(baseColor.slice(5, 7), 16);
      let newR = Math.round(baseR * factor);
      let newG = Math.round(baseG * factor);
      let newB = Math.round(baseB * factor);
      return '#' + ((1 << 24) + (newR << 16) + (newG << 8) + newB).toString(16).slice(1);
    }

    getBorderColor(user) {
      if (user.team === 2) return '#FF0000';
      if (user.team === 3) return '#0000FF';
      if (user.team === 0) return '#000000';
      return this.brightenColor(user.customColor);
    }

    getLuminance(customColorNum) {
      let hex = this.rgbNumberToHex(customColorNum);
      let r = parseInt(hex.slice(1, 3), 16);
      let g = parseInt(hex.slice(3, 5), 16);
      let b = parseInt(hex.slice(5, 7), 16);
      return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
    }

    brightenColor(customColorNum) {
      let hex = this.rgbNumberToHex(customColorNum);
      if (hex === '#000000') return '#FFFFFF';
      let r = parseInt(hex.slice(1, 3), 16);
      let g = parseInt(hex.slice(3, 5), 16);
      let b = parseInt(hex.slice(5, 7), 16);
      let max = Math.max(r, g, b);
      if (max < 128) {
        let factor = 255 / max;
        r = Math.min(255, Math.round(r * factor));
        g = Math.min(255, Math.round(g * factor));
        b = Math.min(255, Math.round(b * factor));
      }
      return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
    }

    rgbNumberToHex(rgb) {
      return '#' + ('000000' + ((rgb & 0xFFFFFF) >>> 0).toString(16)).slice(-6);
    }

    addMessage(html, isOwn = false) {
      this.chatMessages.push({ html, isOwn });
      if (this.chatDialog && this.chatDialog.style.display !== 'none') {
        this.updateChatDialog();
      }
    }

    clearMessages() {
      this.chatMessages = [];
      if (this.chatDialog && this.chatDialog.style.display !== 'none') {
        this.updateChatDialog();
      }
    }

    escapeHtml(unsafe) {
      return unsafe.replace(/[&<>"']/g, function (m) {
        if (m === '&') return '&amp;';
        if (m === '<') return '&lt;';
        if (m === '>') return '&gt;';
        if (m === '"') return '&quot;';
        if (m === "'") return '&#039;';
        return m;
      });
    }

    isValidValue(str) {
      if (str.toLowerCase() === 'true' || str.toLowerCase() === 'false') return true;
      return /^-?\d+(\.\d+)?$/.test(str);
    }

    findSettingByInput(input) {
      if (input.startsWith('@')) {
        let abbr = input.substring(1);
        if (this.abbrToName.has(abbr)) {
          return { type: 'setting', abbr: abbr, name: this.abbrToName.get(abbr) };
        }
        if (this.gameSettings && this.gameSettings.hasOwnProperty(abbr)) {
          return { type: 'setting', abbr: abbr, name: abbr };
        }
        return null;
      }
      let lowerInput = input.toLowerCase();
      if (this.nameToAbbr.hasOwnProperty(lowerInput)) {
        let abbr = this.nameToAbbr[lowerInput];
        return { type: 'setting', abbr: abbr, name: this.abbrToName.get(abbr) || abbr };
      }
      if (this.abbrToName.has(input)) {
        return { type: 'setting', abbr: input, name: this.abbrToName.get(input) };
      }
      const matchedCat = this.categoryNames.find(cat => cat === lowerInput);
      if (matchedCat) {
        let category = matchedCat.charAt(0).toUpperCase() + matchedCat.slice(1);
        return { type: 'category', category: category };
      }
      if (this.gameSettings && this.gameSettings.hasOwnProperty(input)) {
        return { type: 'setting', abbr: input, name: input };
      }
      return null;
    }

    processCommand(user, msg, isOutgoing) {
      if (!msg.startsWith('!')) return false;
      let parts = msg.slice(1).split(' ');
      let cmd = parts[0].toLowerCase();
      let args = parts.slice(1).join(' ').trim();

      if (cmd === 'help') {
        const helpText = [
          '<span style="color:#00FF00">!get &lt;setting|@abbr|category&gt;</span> – Show current value of a setting, or list all settings in a category.',
          '<span style="color:#00FF00">!set &lt;setting|@abbr&gt; &lt;value&gt;</span> – Change a setting (host only).',
          '<span style="color:#00FF00">!reset &lt;setting|@abbr&gt;</span> – Reset a specific setting to its initial value (host only).',
          '<span style="color:#00FF00">!getall</span> – List all known settings, showing current value (or default if not changed).',
          '<span style="color:#00FF00">!sync</span> – Adjust settings based on current timeScale.',
          '<span style="color:#00FF00">!dumpjo</span> – Log full settings object to console (F12).',
          '<span style="color:#AAAAAA">!togglechat</span> – Hide/show the in‑game message area. Hold Shift to see last messages.',
          '<span style="color:#00FF00">!help</span> – Show this message.'
        ];
        helpText.forEach(line => this.display(line, '#FFFFFF', true));
        return true;
      }

      if (cmd === 'set' || cmd === 'sync' || cmd === 'reset') {
        if (this.myid === -1 || this.hostId === -2) {
          this.display('Host status unknown. Try again in a moment.', '#FFA500');
          return true;
        }
        if (this.myid !== this.hostId) {
          this.display('Only the host can use this command.', '#FF0000');
          return true;
        }
      }

      if (cmd === 'get') {
        if (!args) {
          this.display('Usage: !get <setting|@abbr|category> (e.g., !get rkAimRate, !get @Nt, !get bat)', '#FFA500');
          return true;
        }
        let result = this.findSettingByInput(args);
        if (!result) return true;
        if (result.type === 'category') {
          let category = result.category;
          let items = this.settingsMap.filter(item => item.category.toLowerCase() === category.toLowerCase());
          if (items.length === 0) {
            this.display(`No settings found in category ${category}.`, '#FF0000');
            return true;
          }
          let lines = [];
          lines.push(`<span style="color:#FFA500; font-weight:bold;">--- ${category} ---</span>`);
          items.sort((a, b) => a.name.localeCompare(b.name));
          for (let item of items) {
            let val = this.getSettingValue(item.abbr);
            if (val === undefined) continue;
            if (typeof val === 'boolean') val = val ? 'true' : 'false';
            lines.push(`<span style="color:#00FF00">${item.name} (${item.abbr})</span>=<span style="color:#FFFF00">${val}</span>`);
          }
          this.sendChunked(lines);
          return true;
        } else {
          let value = this.getSettingValue(result.abbr);
          if (value === undefined) return true;
          if (typeof value === 'boolean') value = value ? 'true' : 'false';
          let html = `<span style="color:#00FF00">${result.name} (${result.abbr})</span> = <span style="color:#FFFF00">${value}</span>`;
          this.display(html, '#00FF00', true);
          return true;
        }
      }

      if (cmd === 'dumpjo') {
        if (this.gameSettings) {
          //console.log('=== Full game settings ===', this.gameSettings);
          this.display('Full settings object logged to console (F12).', '#AAAAAA');
        } else {
          this.display('gameSettings not available.', '#FF0000');
        }
        return true;
      }

      if (cmd === 'getall') {
        if (!this.gameSettings && !this.roomDefaults) {
          this.display('Settings not available yet. Try again in a moment.', '#FF0000');
          return true;
        }
        let lines = [];
        for (let cat of this.categoryOrder) {
          const items = this.settingsMap.filter(item => item.category.toLowerCase() === cat.toLowerCase());
          if (items.length === 0) continue;
          lines.push(`<span style="color:#FFA500; font-weight:bold;">--- ${cat} ---</span>`);
          items.sort((a, b) => a.name.localeCompare(b.name));
          for (let item of items) {
            let val = this.getSettingValue(item.abbr);
            let displayVal = (val !== undefined) ? (typeof val === 'boolean' ? (val ? 'true' : 'false') : val) : 'N/A';
            lines.push(`<span style="color:#00FF00">${item.name} (${item.abbr})</span>=<span style="color:#FFFF00">${displayVal}</span>`);
          }
        }
        if (lines.length === 0) {
          this.display('No settings found.', '#FF0000');
        } else {
          this.sendChunked(lines);
        }
        return true;
      }

      if (cmd === 'set') {
        let setParts = args.split(' ');
        if (setParts.length < 2) {
          this.display('Usage: !set <setting|@abbr> <value>', '#FFA500');
          return true;
        }
        let settingInput = setParts[0];
        let settingValue = setParts.slice(1).join(' ').trim();
        if (!this.isValidValue(settingValue)) return false;
        let result = this.findSettingByInput(settingInput);
        if (!result || result.type !== 'setting') return false;
        let numValue = parseFloat(settingValue);
        let finalValue = isNaN(numValue) ? settingValue : numValue;
        if (settingValue.toLowerCase() === 'true') finalValue = true;
        if (settingValue.toLowerCase() === 'false') finalValue = false;
        let update = {};
        update[result.abbr] = finalValue;
        if (this.ws) {
          this.ws.send('42' + JSON.stringify([1, [62, update]]));
          setTimeout(() => this.restoreTeams(), 50);
        } else {
          this.display('WebSocket not available.', '#FF0000');
          return true;
        }
        if (this.gameSettings) this.gameSettings[result.abbr] = finalValue;
        if (this.gameSettingsObject) this.gameSettingsObject[result.abbr] = finalValue;
        this.display(`Setting ${result.name} changed to ${finalValue}.`, '#00FF00');
        if (result.name.toLowerCase() === 'timescale') {
          setTimeout(() => {
            this.display('TimeScale changed. Use !sync to adjust related settings.', '#AAAAAA');
          }, 500);
        }
        return true;
      }

      if (cmd === 'reset') {
        if (!args) {
          this.display('Usage: !reset <setting|@abbr>', '#FFA500');
          return true;
        }
        let result = this.findSettingByInput(args);
        if (!result || result.type !== 'setting') return false;
        if (!this.roomDefaults) {
          this.display('Room defaults not available. Use !getall to populate.', '#FF0000');
          return true;
        }
        let initialValue = this.roomDefaults[result.abbr];
        if (initialValue === undefined) {
          this.display(`No default value found for ${result.name}.`, '#FF0000');
          return true;
        }
        let update = {};
        update[result.abbr] = initialValue;
        if (this.ws) {
          this.ws.send('42' + JSON.stringify([1, [62, update]]));
          setTimeout(() => this.restoreTeams(), 50);
        } else {
          this.display('WebSocket not available.', '#FF0000');
          return true;
        }
        if (this.gameSettings) this.gameSettings[result.abbr] = initialValue;
        if (this.gameSettingsObject) this.gameSettingsObject[result.abbr] = initialValue;
        this.display(`Setting ${result.name} reset to ${initialValue}.`, '#00FF00');
        return true;
      }

      if (cmd === 'sync') {
        if (!this.gameSettings) {
          this.display('gameSettings not fully loaded. Try again in a moment.', '#FF0000');
          return true;
        }
        let baseline = this.roomDefaults;
        if (!baseline) {
          this.display('Room defaults not available. Use !getall to populate.', '#FF0000');
          return true;
        }
        let currentTimeScale = this.gameSettings.J;
        if (currentTimeScale === undefined) {
          this.display('TimeScale not found.', '#FF0000');
          return true;
        }
        let factorUp = 30 / currentTimeScale;
        let factorDown = currentTimeScale / 30;

        let baseRkAimRate = baseline.Nt;
        let basePushReplenish = baseline.tt;
        let baseBbFireFramesLength = baseline.at;
        let baseBbHideAfterFireFrames = baseline.lt;
        let baseBbHoldAmmoCost = baseline.rt;
        let baseBbResetOn = baseline.ut;
        let baseBbFireOn = baseline.nt;

        const missing = [];
        if (baseRkAimRate === undefined) missing.push('rkAimRate (Nt)');
        if (basePushReplenish === undefined) missing.push('pushAmmoReplenish (tt)');
        if (baseBbFireFramesLength === undefined) missing.push('bbFireFramesLength (at)');
        if (baseBbHideAfterFireFrames === undefined) missing.push('bbHideAfterFireFrames (lt)');
        if (baseBbHoldAmmoCost === undefined) missing.push('bbHoldAmmoCost (rt)');
        if (baseBbResetOn === undefined) missing.push('bbResetOn (ut)');
        if (baseBbFireOn === undefined) missing.push('bbFireOn (nt)');

        if (missing.length > 0) {
          this.display(`Missing baseline settings: ${missing.join(', ')}. They may not exist in this game mode.`, '#FF0000');
          return true;
        }

        let newRkAimRate = baseRkAimRate * factorUp;
        let newPushReplenish = basePushReplenish * factorUp;
        let newBbFireFramesLength = baseBbFireFramesLength * factorDown;
        let newBbHideAfterFireFrames = baseBbHideAfterFireFrames * factorDown;
        let newBbHoldAmmoCost = baseBbHoldAmmoCost * factorUp;
        let newBbResetOn = Math.round(baseBbResetOn * factorDown);
        let newBbFireOn = Math.round(baseBbFireOn * factorDown);

        let updates = {
          Nt: newRkAimRate,
          tt: newPushReplenish,
          at: newBbFireFramesLength,
          lt: newBbHideAfterFireFrames,
          rt: newBbHoldAmmoCost,
          ut: newBbResetOn,
          nt: newBbFireOn
        };
        //console.log('[Commander] Sending sync updates:', updates);
        if (this.ws) {
          this.ws.send('42' + JSON.stringify([1, [62, updates]]));
          setTimeout(() => this.restoreTeams(), 50);
        } else {
          this.display('WebSocket not available.', '#FF0000');
          return true;
        }

        if (this.gameSettings) {
          this.gameSettings.Nt = newRkAimRate;
          this.gameSettings.tt = newPushReplenish;
          this.gameSettings.at = newBbFireFramesLength;
          this.gameSettings.lt = newBbHideAfterFireFrames;
          this.gameSettings.rt = newBbHoldAmmoCost;
          this.gameSettings.ut = newBbResetOn;
          this.gameSettings.nt = newBbFireOn;
        }
        if (this.gameSettingsObject) {
          this.gameSettingsObject.Nt = newRkAimRate;
          this.gameSettingsObject.tt = newPushReplenish;
          this.gameSettingsObject.at = newBbFireFramesLength;
          this.gameSettingsObject.lt = newBbHideAfterFireFrames;
          this.gameSettingsObject.rt = newBbHoldAmmoCost;
          this.gameSettingsObject.ut = newBbResetOn;
          this.gameSettingsObject.nt = newBbFireOn;
        }

        this.display(`Synced: rkAimRate = ${newRkAimRate.toFixed(2)}, pushAmmoReplenish = ${newPushReplenish.toFixed(2)}, bbFireFramesLength = ${newBbFireFramesLength.toFixed(2)}, bbHideAfterFireFrames = ${newBbHideAfterFireFrames.toFixed(2)}, bbHoldAmmoCost = ${newBbHoldAmmoCost.toFixed(2)}, bbResetOn = ${newBbResetOn}, bbFireOn = ${newBbFireOn} (timeScale = ${currentTimeScale})`, '#00FF00');
        return true;
      }

      if (cmd === 'togglechat') {
        this.chatHidden = !this.chatHidden;
        localStorage.setItem('chatHidden', this.chatHidden);
        this.applyChatHidden();
        this.display(`In-game message area ${this.chatHidden ? 'hidden' : 'visible'}. Input always visible.`, '#AAAAAA');
        return true;
      }

      return false;
    }

    sendChunked(lines) {
      const CHUNK_SIZE = 8;
      for (let i = 0; i < lines.length; i += CHUNK_SIZE) {
        let chunk = lines.slice(i, i + CHUNK_SIZE).join('<br>');
        this.display(chunk, '#00FF00', true);
      }
    }

    display(text, color, isHtml = false) {
      let chatBoxes = document.querySelectorAll('.chatBox .content, .inGameChat .content');
      if (!chatBoxes.length) return;
      let div = document.createElement('div');
      div.classList.add('statusContainer');
      let span = document.createElement('span');
      span.classList.add('status');
      span.style.backgroundColor = 'rgba(37, 38, 42, 0.768627451)';
      span.style.borderRadius = '7px';
      span.style.padding = '2px 5px';
      span.style.display = 'inline-block';
      span.style.margin = '2px 0';
      span.style.whiteSpace = 'pre-wrap';
      span.style.wordBreak = 'break-word';
      if (isHtml) {
        span.innerHTML = `<span style="color:purple">[Bot]</span> ${text}`;
      } else {
        span.innerHTML = `<span style="color:purple">[Bot]</span> <span style="color:${color}">${this.escapeHtml(text)}</span>`;
      }
      div.appendChild(span);
      let botHtml = span.innerHTML;
      this.addMessage(botHtml, false);
      for (let box of chatBoxes) {
        let clone = div.cloneNode(true);
        box.appendChild(clone);
        box.scrollTop = box.scrollHeight;
        if (box.closest('.inGameChat')) {
          setTimeout(() => {
            if (clone.parentNode) clone.remove();
          }, 5000);
        }
      }
    }

    initChatToggle() {
      //console.log('[Commander] initChatToggle - chatHidden =', this.chatHidden);
      this.applyChatHidden();
      setInterval(() => {
        if (this.chatHidden && !this.tempChatVisible) {
          this.applyChatHidden();
        }
      }, 1000);
      window.addEventListener('keydown', (e) => {
        if (e.key === 'Shift') {
          //console.log('[Commander] Shift down');
          this.showChatDialog();
        }
      });
      window.addEventListener('keyup', (e) => {
        if (e.key === 'Shift') {
          //console.log('[Commander] Shift up');
          this.hideChatDialog();
        }
      });
    }

    applyChatHidden() {
      const chatContent = document.querySelector('.inGameChat .content');
      if (!chatContent) {
        //console.log('[Commander] Chat content not found');
        return;
      }
      if (this.chatHidden && !this.tempChatVisible) {
        chatContent.style.setProperty('display', 'none', 'important');
        //console.log('[Commander] Messages hidden');
      } else {
        chatContent.style.display = '';
        //console.log('[Commander] Messages visible');
      }
    }

    createChatDialog() {
      this.chatDialog = document.createElement('div');
      this.chatDialog.id = 'commander-chat-dialog';
      this.chatDialog.style.position = 'fixed';
      this.chatDialog.style.bottom = '60px';
      this.chatDialog.style.left = '10px';
      this.chatDialog.style.width = '300px';
      this.chatDialog.style.maxHeight = '200px';
      this.chatDialog.style.overflowY = 'auto';
      this.chatDialog.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
      this.chatDialog.style.color = 'white';
      this.chatDialog.style.fontFamily = 'Arial, sans-serif';
      this.chatDialog.style.fontSize = '14px';
      this.chatDialog.style.padding = '8px';
      this.chatDialog.style.borderRadius = '8px';
      this.chatDialog.style.backdropFilter = 'blur(2px)';
      this.chatDialog.style.zIndex = '10000';
      this.chatDialog.style.display = 'none';
      this.chatDialog.style.pointerEvents = 'auto';
      this.chatDialog.addEventListener('scroll', () => {
        if (this.chatDialog) {
          const atBottom = this.chatDialog.scrollHeight - this.chatDialog.scrollTop <= this.chatDialog.clientHeight + 5;
          this.userScrolled = !atBottom;
        }
      });
      document.body.appendChild(this.chatDialog);
    }

    updateChatDialog() {
      if (!this.chatDialog) return;
      let html = '';
      for (let m of this.chatMessages) {
        let extraStyle = m.isOwn ? 'border-left: 2px solid gold; background-color: rgba(255,215,0,0.1); padding-left: 4px;' : '';
        html += `<div style="margin-bottom: 2px; ${extraStyle}">${m.html}</div>`;
      }
      this.chatDialog.innerHTML = html || '<i>No messages yet</i>';
      if (!this.userScrolled) {
        setTimeout(() => {
          if (this.chatDialog) {
            this.chatDialog.scrollTop = this.chatDialog.scrollHeight;
          }
        }, 0);
      }
    }

    showChatDialog() {
      if (!this.chatDialog) return;
      this.userScrolled = false;
      this.updateChatDialog();
      this.chatDialog.style.display = 'block';
    }

    hideChatDialog() {
      if (!this.chatDialog) return;
      this.chatDialog.style.display = 'none';
    }
  }

  // ---- Modified keydown handler to allow non-mod keys even when input is focused ----
  const originalAddEventListener = window.addEventListener;
  window.addEventListener = function (type, listener, options) {
    if (type === 'keydown') {
      const wrappedListener = function (e) {
        const isModKey = (e.which == 65 && e.shiftKey) || (e.which == 83 && e.shiftKey) || (e.which == 70 && e.shiftKey) ||
          (() => {
            if (typeof macro_scripts !== 'undefined' && macro_scripts) {
              let comb = ((e.shiftKey ? 'shift+' : '') + (e.altKey ? 'alt+' : '') + e.key.toLowerCase()).split('+');
              for (let i in macro_scripts) {
                let keycomb = i.toLowerCase().split('+');
                let kc = true;
                for (let y of keycomb) if (!comb.includes(y)) kc = false;
                for (let y of comb) if (!keycomb.includes(y)) kc = false;
                if (kc) return true;
              }
            }
            return false;
          })();
        if (document.activeElement && document.activeElement.tagName == "INPUT" && !isModKey && e.which != 13) {
          e.stopImmediatePropagation();
          return;
        }
        listener(e);
      };
      return originalAddEventListener.call(this, type, wrappedListener, options);
    }
    return originalAddEventListener.call(this, type, listener, options);
  };

  if (document.readyState === 'complete') {
    new HitboxCommander();
  } else {
    window.addEventListener('load', () => new HitboxCommander());
  }
})();