๐ Online Multiplayer ยท Tic-Tac-Toe
by SonicBear35661 lines19.1 KB
<!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.