๐ŸŽฎArcadeLab

๐ŸŒ Online Multiplayer ยท Tic-Tac-Toe

by SonicBear35
661 lines19.1 KB
โ–ถ Play
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>๐ŸŒ Online Multiplayer ยท Tic-Tac-Toe</title>
  <style>
    * {
      box-sizing: border-box;
      margin: 0;
      user-select: none;
    }
    body {
      background: linear-gradient(145deg, #0b1628, #1a2a3f);
      min-height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
      font-family: 'Segoe UI', Roboto, system-ui, sans-serif;
      padding: 1rem;
      margin: 0;
    }
    .game-container {
      background: rgba(20, 34, 54, 0.7);
      backdrop-filter: blur(6px);
      border-radius: 3.5rem;
      padding: 2rem 2rem 2.5rem;
      box-shadow: 0 25px 45px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.06);
      max-width: 650px;
      width: 100%;
      transition: 0.2s;
    }
    h1 {
      text-align: center;
      font-weight: 500;
      letter-spacing: 2px;
      font-size: 2rem;
      color: #e8effa;
      text-shadow: 0 2px 0 #0b111e;
      margin-bottom: 0.4rem;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 0.4rem;
      flex-wrap: wrap;
    }
    h1 span {
      background: #2d405b;
      padding: 0.15rem 1.2rem;
      border-radius: 60px;
      font-size: 1.1rem;
      font-weight: 600;
      color: #b7cbee;
      box-shadow: inset 0 2px 4px rgba(0,0,0,0.5);
    }
    .connection-panel {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      justify-content: center;
      gap: 0.7rem;
      background: #141f32;
      padding: 0.8rem 1.2rem;
      border-radius: 80px;
      margin: 0.6rem 0 1rem;
      border: 1px solid #3d5270;
      box-shadow: inset 0 1px 6px #0a111f;
    }
    .connection-panel input {
      background: #0b1424;
      border: none;
      padding: 0.6rem 1.2rem;
      border-radius: 60px;
      color: #e6edfc;
      font-weight: 500;
      font-size: 0.95rem;
      min-width: 160px;
      flex: 1;
      outline: 1px solid #384b69;
      transition: 0.15s;
      box-shadow: inset 0 2px 6px #03070e;
    }
    .connection-panel input:focus {
      outline: 2px solid #6d8fc9;
      background: #101d33;
    }
    .connection-panel input::placeholder {
      color: #6b7f9e;
      font-weight: 400;
    }
    .btn {
      background: #2b3f5c;
      border: none;
      border-radius: 60px;
      padding: 0.6rem 1.6rem;
      font-weight: 600;
      font-size: 1rem;
      color: #eef3fc;
      box-shadow: 0 5px 0 #0e1729, 0 4px 12px rgba(0,0,0,0.6);
      cursor: pointer;
      transition: 0.06s linear;
      display: inline-flex;
      align-items: center;
      gap: 0.4rem;
      border: 1px solid #4d688b;
      letter-spacing: 0.6px;
      white-space: nowrap;
    }
    .btn:active {
      transform: translateY(4px);
      box-shadow: 0 1px 0 #0e1729;
    }
    .btn.connect {
      background: #2f5a6b;
      border-color: #5f8aa3;
    }
    .btn.connect:hover {
      background: #3e7388;
    }
    .btn.reset {
      background: #3d3f66;
      border-color: #6b7ba0;
    }
    .btn.reset:hover {
      background: #4f5480;
    }
    .btn:disabled {
      opacity: 0.5;
      transform: translateY(2px);
      box-shadow: 0 2px 0 #0e1729;
      pointer-events: none;
    }
    .status-badge {
      background: #1f2d44;
      border-radius: 40px;
      padding: 0.3rem 1.2rem;
      font-size: 0.9rem;
      font-weight: 500;
      color: #b7ccec;
      border: 1px solid #3a5070;
      display: inline-flex;
      align-items: center;
      gap: 0.5rem;
    }
    .turn-indicator {
      background: #1a273d;
      border-radius: 100px;
      padding: 0.4rem 1.2rem;
      margin: 0.4rem auto 1rem;
      display: inline-flex;
      align-items: center;
      gap: 0.8rem;
      font-weight: 600;
      font-size: 1.2rem;
      color: #d3def5;
      box-shadow: inset 0 2px 6px rgba(0,0,0,0.6);
      border: 1px solid #3f5678;
    }
    .turn-indicator .player-badge {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 2.2rem;
      height: 2.2rem;
      border-radius: 60px;
      background: #0f1a2d;
      font-weight: 700;
      font-size: 1.6rem;
      box-shadow: 0 2px 0 #364d6b;
      color: #f1f5f9;
    }
    .turn-indicator .player-badge.x { color: #7aa9ff; }
    .turn-indicator .player-badge.o { color: #f7b87e; }
    .board {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 0.8rem;
      aspect-ratio: 1 / 1;
      width: 100%;
      max-width: 440px;
      margin: 0.6rem auto 1.2rem;
    }
    .cell {
      background: #1b2a40;
      border-radius: 28px;
      box-shadow: inset 0 -6px 0 #0b1322, 0 8px 16px rgba(0,0,0,0.6);
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 4.5rem;
      font-weight: 700;
      color: #eef3fc;
      transition: 0.08s ease;
      cursor: pointer;
      aspect-ratio: 1 / 1;
      text-shadow: 0 3px 0 #0a111f;
      border: 1px solid #3a5172;
    }
    .cell:active { transform: scale(0.94); box-shadow: inset 0 -2px 0 #0b1322; }
    .cell.x-move { color: #7aa9ff; text-shadow: 0 0 14px #3f7bff77; }
    .cell.o-move { color: #f7b87e; text-shadow: 0 0 14px #f7a54b77; }
    .cell.win-highlight {
      background: #2f4b5a;
      box-shadow: 0 0 20px #f5c54290, inset 0 -4px 0 #9e7d2e;
      border-color: #f5c542;
      transform: scale(1.02);
    }
    .cell.disabled-cell {
      cursor: default;
      opacity: 0.9;
    }
    .status-area {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      justify-content: space-between;
      gap: 0.8rem;
      margin-top: 0.5rem;
    }
    .message {
      background: #111f31;
      padding: 0.5rem 1.6rem;
      border-radius: 60px;
      font-weight: 500;
      font-size: 1.1rem;
      color: #dae4f5;
      box-shadow: inset 0 2px 6px #060c17;
      border: 1px solid #344b69;
      flex: 1;
      min-width: 140px;
      text-align: center;
    }
    .message.win { background: #2f4b3f; border-color: #82c99a; color: #d4f5d4; }
    .footer {
      display: flex;
      justify-content: center;
      margin-top: 1rem;
      color: #6b7f9c;
      font-size: 0.8rem;
      gap: 0.8rem;
      flex-wrap: wrap;
    }
    .footer span { background: #19273e; padding: 0.15rem 1rem; border-radius: 40px; }
    .peer-id-box {
      background: #0f1a2b;
      padding: 0.2rem 1.2rem;
      border-radius: 40px;
      font-family: monospace;
      font-size: 0.9rem;
      color: #aec3e8;
      border: 1px solid #384f70;
    }
    @media (max-width: 480px) {
      .game-container { padding: 1.2rem 1rem; }
      .cell { font-size: 3rem; border-radius: 18px; }
      .turn-indicator { font-size: 1rem; padding: 0.2rem 1rem; }
      .btn { padding: 0.4rem 1rem; font-size: 0.9rem; }
      .connection-panel { padding: 0.6rem 0.8rem; }
    }
  </style>
</head>
<body>
<div class="game-container">
  <h1>โœ–๏ธ Tic-Tac-Toe <span>online</span></h1>

  <!-- connection panel -->
  <div class="connection-panel">
    <input type="text" id="roomInput" placeholder="Room ID" value="tictac-2026">
    <button class="btn connect" id="hostBtn">๐ŸŸข Host</button>
    <button class="btn connect" id="joinBtn">๐Ÿ”— Join</button>
    <div class="peer-id-box" id="peerIdDisplay">๐Ÿ”‘ your-id</div>
  </div>

  <div style="display: flex; justify-content: center;">
    <div class="turn-indicator" id="turnIndicator">
      <span>Turn</span>
      <span class="player-badge x" id="turnBadge">X</span>
    </div>
  </div>

  <div class="board" id="board"></div>

  <div class="status-area">
    <div class="message" id="statusMessage">โณ Waiting for connection...</div>
    <button class="btn reset" id="resetBtn">โŸณ Reset</button>
  </div>
  <div class="footer">
    <span>๐Ÿ‘ฅ peer-to-peer</span>
    <span id="connectionStatus">โšช disconnected</span>
  </div>
</div>

<!-- PeerJS library -->
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
<script>
  (function(){
    // ---------- DOM refs ----------
    const boardEl = document.getElementById('board');
    const statusMsg = document.getElementById('statusMessage');
    const turnBadge = document.getElementById('turnBadge');
    const turnIndicator = document.getElementById('turnIndicator');
    const resetBtn = document.getElementById('resetBtn');
    const hostBtn = document.getElementById('hostBtn');
    const joinBtn = document.getElementById('joinBtn');
    const roomInput = document.getElementById('roomInput');
    const peerIdDisplay = document.getElementById('peerIdDisplay');
    const connectionStatus = document.getElementById('connectionStatus');

    // ---------- Game state ----------
    let board = Array(9).fill(null);
    let currentPlayer = 'X';          // local player's mark (X or O)
    let gameActive = false;
    let winCombo = null;
    let localMark = 'X';             // what we play as
    let turn = 'X';                  // whose turn globally

    // ---------- PeerJS ----------
    let peer = null;
    let conn = null;                 // active data connection
    let roomId = '';

    // ---------- Render ----------
    function renderBoard() {
      boardEl.innerHTML = '';
      for (let i = 0; i < 9; i++) {
        const cell = document.createElement('div');
        cell.classList.add('cell');
        if (!gameActive || (turn !== localMark) || board[i] !== null || !conn) {
          cell.classList.add('disabled-cell');
        }
        cell.dataset.index = i;
        cell.textContent = board[i] || '';
        if (board[i] === 'X') cell.classList.add('x-move');
        else if (board[i] === 'O') cell.classList.add('o-move');

        if (winCombo && winCombo.includes(i)) {
          cell.classList.add('win-highlight');
        }

        cell.addEventListener('click', cellClickHandler);
        boardEl.appendChild(cell);
      }
      updateUI();
    }

    // ---------- Click handler ----------
    function cellClickHandler(e) {
      const index = parseInt(e.currentTarget.dataset.index, 10);
      if (!gameActive || !conn || turn !== localMark || board[index] !== null) {
        return;
      }
      // make move locally
      makeMove(index, localMark);
    }

    // execute a move (called locally or from remote)
    function makeMove(index, player) {
      if (board[index] !== null) return false;
      if (!gameActive) return false;
      if (player !== turn) return false;

      // apply move
      board[index] = player;
      const win = checkWinner();
      if (win) {
        winCombo = win;
        gameActive = false;
        renderBoard();
        statusMsg.textContent = `๐Ÿ† ${player} wins!`;
        statusMsg.classList.add('win');
        turnBadge.textContent = '๐Ÿ';
        turnBadge.className = 'player-badge';
        // send win to peer
        if (conn) {
          conn.send({ type: 'win', winner: player, combo: win });
        }
        return true;
      } else if (board.every(c => c !== null)) {
        // draw
        gameActive = false;
        winCombo = null;
        renderBoard();
        statusMsg.textContent = `๐Ÿค It's a draw!`;
        statusMsg.classList.add('win');
        turnBadge.textContent = 'โš–๏ธ';
        turnBadge.className = 'player-badge';
        if (conn) {
          conn.send({ type: 'draw' });
        }
        return true;
      }

      // switch turn
      turn = (turn === 'X' ? 'O' : 'X');
      renderBoard();
      statusMsg.textContent = `โ–ถ๏ธ ${turn}'s turn`;
      statusMsg.classList.remove('win');

      // send move to peer
      if (conn) {
        conn.send({ type: 'move', index, player, turn: turn });
      }
      return true;
    }

    // ---------- Check winner ----------
    function checkWinner() {
      const patterns = [
        [0,1,2],[3,4,5],[6,7,8],
        [0,3,6],[1,4,7],[2,5,8],
        [0,4,8],[2,4,6]
      ];
      for (let p of patterns) {
        const [a,b,c] = p;
        if (board[a] && board[a] === board[b] && board[a] === board[c]) {
          return p;
        }
      }
      return null;
    }

    // ---------- UI update ----------
    function updateUI() {
      if (!gameActive) return;
      turnBadge.textContent = turn;
      turnBadge.className = 'player-badge ' + (turn === 'X' ? 'x' : 'o');
    }

    // ---------- Reset (local) ----------
    function resetGame() {
      board = Array(9).fill(null);
      winCombo = null;
      gameActive = true;
      turn = 'X';   // X always starts
      statusMsg.classList.remove('win');
      statusMsg.textContent = `โœจ New game ยท ${turn}'s turn`;
      turnBadge.textContent = 'X';
      turnBadge.className = 'player-badge x';
      renderBoard();
      // broadcast reset if connected
      if (conn) {
        conn.send({ type: 'reset' });
      }
    }

    // ---------- Connection & PeerJS ----------
    function initPeer(role, room) {
      if (peer) {
        peer.destroy();
        peer = null;
      }
      if (conn) {
        conn.close();
        conn = null;
      }

      // generate random id for each session
      const id = 'ttt-' + Math.random().toString(36).substring(2, 8);
      peer = new Peer(id, {
        debug: 2,
      });

      peer.on('open', (id) => {
        peerIdDisplay.textContent = `๐Ÿ”‘ ${id}`;
        connectionStatus.textContent = '๐ŸŸก connecting...';
        connectionStatus.style.color = '#f5c542';

        if (role === 'host') {
          // host waits for connection
          peer.on('connection', (incoming) => {
            if (conn) {
              incoming.close();
              return;
            }
            conn = incoming;
            setupConnection(conn, 'host');
          });
          statusMsg.textContent = `๐ŸŸข Hosting room: ${room}`;
          connectionStatus.textContent = 'โณ waiting for player...';
        } else if (role === 'join') {
          // join: connect to host
          const targetId = room.trim() || 'tictac-2026';
          const newConn = peer.connect(targetId, { reliable: true });
          conn = newConn;
          setupConnection(newConn, 'join');
        }
      });

      peer.on('error', (err) => {
        console.warn('Peer error:', err);
        connectionStatus.textContent = 'โŒ connection error';
        connectionStatus.style.color = '#f28b82';
        statusMsg.textContent = 'โš ๏ธ Peer error โ€” try again';
        if (err.type === 'peer-unavailable') {
          statusMsg.textContent = 'โŒ Room not found. Host first.';
        }
      });
    }

    function setupConnection(connection, role) {
      connection.on('open', () => {
        connectionStatus.textContent = '๐ŸŸข connected';
        connectionStatus.style.color = '#81c995';
        statusMsg.textContent = 'โœ… Connected! X goes first.';
        // set marks: host = X, join = O
        if (role === 'host') {
          localMark = 'X';
        } else {
          localMark = 'O';
        }
        turn = 'X';   // X always starts
        gameActive = true;
        resetGame();  // initial board
        // send handshake
        connection.send({ type: 'handshake', mark: localMark, turn: 'X' });
        renderBoard();
      });

      connection.on('data', (data) => {
        handleRemoteData(data);
      });

      connection.on('close', () => {
        connectionStatus.textContent = '๐Ÿ”ด disconnected';
        connectionStatus.style.color = '#f28b82';
        statusMsg.textContent = 'โš ๏ธ Peer disconnected';
        gameActive = false;
        conn = null;
        renderBoard();
      });

      connection.on('error', (e) => {
        console.warn('conn error', e);
        connectionStatus.textContent = 'โš ๏ธ connection error';
        connectionStatus.style.color = '#f28b82';
      });
    }

    // ---------- Handle remote messages ----------
    function handleRemoteData(data) {
      if (!data) return;
      switch (data.type) {
        case 'handshake': {
          // set remote mark
          const remoteMark = (data.mark === 'X' ? 'O' : 'X');
          // ensure we set our mark (if not set)
          if (!localMark) {
            localMark = remoteMark === 'X' ? 'O' : 'X';
          }
          turn = data.turn || 'X';
          gameActive = true;
          resetGame();
          break;
        }
        case 'move': {
          if (!gameActive) break;
          const { index, player } = data;
          if (board[index] !== null) break;
          // apply move from remote
          board[index] = player;
          const win = checkWinner();
          if (win) {
            winCombo = win;
            gameActive = false;
            renderBoard();
            statusMsg.textContent = `๐Ÿ† ${player} wins!`;
            statusMsg.classList.add('win');
            turnBadge.textContent = '๐Ÿ';
            turnBadge.className = 'player-badge';
            return;
          } else if (board.every(c => c !== null)) {
            gameActive = false;
            winCombo = null;
            renderBoard();
            statusMsg.textContent = `๐Ÿค It's a draw!`;
            statusMsg.classList.add('win');
            turnBadge.textContent = 'โš–๏ธ';
            turnBadge.className = 'player-badge';
            return;
          }
          // update turn (remote sends new turn)
          if (data.turn) {
            turn = data.turn;
          } else {
            turn = (turn === 'X' ? 'O' : 'X');
          }
          renderBoard();
          statusMsg.textContent = `โ–ถ๏ธ ${turn}'s turn`;
          statusMsg.classList.remove('win');
          break;
        }
        case 'reset': {
          resetGame();
          break;
        }
        case 'win': {
          gameActive = false;
          winCombo = data.combo || null;
          renderBoard();
          statusMsg.textContent = `๐Ÿ† ${data.winner} wins!`;
          statusMsg.classList.add('win');
          turnBadge.textContent = '๐Ÿ';
          turnBadge.className = 'player-badge';
          break;
        }
        case 'draw': {
          gameActive = false;
          winCombo = null;
          renderBoard();
          statusMsg.textContent = `๐Ÿค It's a draw!`;
          statusMsg.classList.add('win');
          turnBadge.textContent = 'โš–๏ธ';
          turnBadge.className = 'player-badge';
          break;
        }
        default: break;
      }
    }

    // ---------- Button handlers ----------
    hostBtn.addEventListener('click', () => {
      const room = roomInput.value.trim() || 'tictac-2026';
      roomId = room;
      initPeer('host', room);
      hostBtn.disabled = true;
      joinBtn.disabled = true;
      setTimeout(() => { hostBtn.disabled = false; joinBtn.disabled = false; }, 2000);
    });

    joinBtn.addEventListener('click', () => {
      const room = roomInput.value.trim() || 'tictac-2026';
      roomId = room;
      initPeer('join', room);
      hostBtn.disabled = true;
      joinBtn.disabled = true;
      setTimeout(() => { hostBtn.disabled = false; joinBtn.disabled = false; }, 2000);
    });

    resetBtn.addEventListener('click', () => {
      if (!conn) {
        statusMsg.textContent = 'โ›” No active connection';
        return;
      }
      resetGame();
    });

    // initial render
    renderBoard();
    statusMsg.textContent = '๐Ÿ”„ Host or Join a room';
    connectionStatus.textContent = 'โšช disconnected';
    connectionStatus.style.color = '#6b7f9c';

    // cleanup
    window.addEventListener('beforeunload', () => {
      if (peer) peer.destroy();
      if (conn) conn.close();
    });
  })();
</script>
</body>
</html>

Game Source: ๐ŸŒ Online Multiplayer ยท Tic-Tac-Toe

Creator: SonicBear35

Libraries: none

Complexity: complex (661 lines, 19.1 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: online-multiplayer-tic-tac-toe-sonicbear35" to link back to the original. Then publish at arcadelab.ai/publish.