How do I build a 2-player Othello game with HTML, CSS, and JavaScript?
I want to create a browser-based Othello (Reversi) game for two human players using plain HTML, CSS, and JavaScript (no frameworks). The game should include: - An 8x8 game board with the standard starting position (2 white and 2 black pieces in the center) - Click-to-place mechanics for each player's turn - Valid move detection (pieces must outflank opponent pieces) - Automatic flipping of captured pieces - Turn switching between black and white players - Display of current player's turn - Game-over detection (when neither player can move) and score display Please provide complete, working code I can use directly in a single HTML file.
0
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Othello (Reversi) — 2 Player</title>
<style>
:root{
--bg:#0f172a;
--panel:#111827;
--text:#e5e7eb;
--muted:#9ca3af;
--board:#065f46;
--cell:#047857;
--cellBorder: rgba(255,255,255,.15);
--hint: rgba(255,255,255,.18);
--black:#111827;
--white:#f9fafb;
--accent:#60a5fa;
--danger:#f87171;
}
*{box-sizing:border-box}
body{
margin:0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
background: radial-gradient(1200px 600px at 20% 0%, #1f2937, var(--bg));
color:var(--text);
display:flex;
min-height:100vh;
align-items:center;
justify-content:center;
padding:24px;
}
.app{
width:min(980px, 100%);
display:grid;
grid-template-columns: 1fr 280px;
gap:18px;
align-items:start;
}
@media (max-width: 860px){
.app{ grid-template-columns: 1fr; }
}
.boardWrap{
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
border:1px solid rgba(255,255,255,.12);
border-radius:14px;
padding:14px;
box-shadow: 0 20px 60px rgba(0,0,0,.25);
}
.board{
aspect-ratio: 1 / 1;
width:100%;
max-width: 640px;
margin:0 auto;
background: var(--board);
border-radius:12px;
padding:10px;
display:grid;
grid-template-columns: repeat(8, 1fr);
gap:6px;
}
.cell{
border-radius:10px;
background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(0,0,0,.06)), var(--cell);
border:1px solid var(--cellBorder);
position:relative;
cursor:pointer;
user-select:none;
display:flex;
align-items:center;
justify-content:center;
transition: transform .06s ease, filter .12s ease;
}
.cell:hover{ filter: brightness(1.06); }
.cell:active{ transform: scale(.98); }
.disc{
width:78%;
height:78%;
border-radius:50%;
box-shadow:
inset 0 3px 10px rgba(255,255,255,.25),
inset 0 -10px 18px rgba(0,0,0,.35),
0 10px 22px rgba(0,0,0,.28);
}
.disc.black{
background: radial-gradient(circle at 30% 25%, #374151, var(--black) 60%);
}
.disc.white{
background: radial-gradient(circle at 30% 25%, #ffffff, var(--white) 60%);
}
.hint::after{
content:"";
width:28%;
height:28%;
border-radius:50%;
background: var(--hint);
outline: 2px solid rgba(255,255,255,.18);
outline-offset: 2px;
box-shadow: 0 0 0 6px rgba(0,0,0,.08);
}
.side{
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
border:1px solid rgba(255,255,255,.12);
border-radius:14px;
padding:14px;
box-shadow: 0 20px 60px rgba(0,0,0,.25);
}
h1{
margin: 2px 0 10px;
font-size: 18px;
letter-spacing:.2px;
}
.row{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
padding:10px 10px;
border-radius:12px;
background: rgba(0,0,0,.18);
border:1px solid rgba(255,255,255,.08);
margin-bottom:10px;
}
.pill{
display:inline-flex;
align-items:center;
gap:8px;
padding:6px 10px;
border-radius:999px;
border:1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.04);
color:var(--text);
font-size: 13px;
white-space:nowrap;
}
.dot{
width:12px; height:12px; border-radius:50%;
box-shadow: inset 0 1px 2px rgba(255,255,255,.35), 0 2px 8px rgba(0,0,0,.25);
}
.dot.black{ background: var(--black); }
.dot.white{ background: var(--white); border:1px solid rgba(0,0,0,.15); }
.status{
padding:10px;
border-radius:12px;
background: rgba(96,165,250,.12);
border:1px solid rgba(96,165,250,.25);
color: var(--text);
font-size: 14px;
line-height: 1.35;
margin-bottom:10px;
}
.status.gameover{
background: rgba(248,113,113,.12);
border-color: rgba(248,113,113,.25);
}
.controls{
display:flex;
gap:10px;
flex-wrap:wrap;
margin-top:10px;
}
button{
appearance:none;
border:1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.06);
color:var(--text);
padding:10px 12px;
border-radius:12px;
cursor:pointer;
font-weight:600;
letter-spacing:.2px;
}
button:hover{ filter: brightness(1.08); }
button.primary{
background: rgba(96,165,250,.18);
border-color: rgba(96,165,250,.35);
}
.small{
color: var(--muted);
font-size: 12px;
line-height: 1.35;
margin-top:10px;
}
.small code{ color: #d1d5db; }
</style>
</head>
<body>
<div class="app">
<div class="boardWrap">
<div id="board" class="board" aria-label="Othello board"></div>
</div>
<aside class="side">
<h1>Othello (Reversi) — 2 Players</h1>
<div class="row">
<span class="pill"><span class="dot black"></span> Black: <strong id="scoreBlack">2</strong></span>
<span class="pill"><span class="dot white"></span> White: <strong id="scoreWhite">2</strong></span>
</div>
<div id="status" class="status"></div>
<div class="controls">
<button id="newGame" class="primary">New Game</button>
<button id="toggleHints">Hide Hints</button>
</div>
<div class="small">
Rules reminder: You must place a disc that <em>outflanks</em> one or more opponent discs in any of the 8 directions.
If a player has no legal moves, they pass automatically. Game ends when neither player can move.
</div>
</aside>
</div>
<script>
// 0 = empty, 1 = black, -1 = white
const SIZE = 8;
const BLACK = 1;
const WHITE = -1;
const DIRS = [
[-1,-1], [-1,0], [-1,1],
[ 0,-1], [ 0,1],
[ 1,-1], [ 1,0], [ 1,1],
];
const boardEl = document.getElementById('board');
const statusEl = document.getElementById('status');
const scoreBlackEl = document.getElementById('scoreBlack');
const scoreWhiteEl = document.getElementById('scoreWhite');
const newGameBtn = document.getElementById('newGame');
const toggleHintsBtn = document.getElementById('toggleHints');
let board = [];
let current = BLACK;
let gameOver = false;
let showHints = true;
function inBounds(r,c){ return r>=0 && r<SIZE && c>=0 && c<SIZE; }
function initBoard(){
board = Array.from({length: SIZE}, () => Array(SIZE).fill(0));
// Standard start:
// (3,3)=W (3,4)=B (4,3)=B (4,4)=W
board[3][3] = WHITE;
board[3][4] = BLACK;
board[4][3] = BLACK;
board[4][4] = WHITE;
current = BLACK;
gameOver = false;
render();
}
function countDiscs(){
let b=0, w=0;
for (let r=0;r<SIZE;r++){
for (let c=0;c<SIZE;c++){
if (board[r][c] === BLACK) b++;
else if (board[r][c] === WHITE) w++;
}
}
return {b,w};
}
// Returns array of positions to flip if move is made at (r,c) by player.
// Empty array => not a legal move.
function flipsForMove(r, c, player){
if (!inBounds(r,c) || board[r][c] !== 0) return [];
const opponent = -player;
const flips = [];
for (const [dr,dc] of DIRS){
let rr = r + dr, cc = c + dc;
const line = [];
// first must be opponent
while (inBounds(rr,cc) && board[rr][cc] === opponent){
line.push([rr,cc]);
rr += dr; cc += dc;
}
// then must end with player's disc
if (line.length > 0 && inBounds(rr,cc) && board[rr][cc] === player){
flips.push(...line);
}
}
return flips;
}
function legalMoves(player){
const moves = [];
for (let r=0;r<SIZE;r++){
for (let c=0;c<SIZE;c++){
const flips = flipsForMove(r,c,player);
if (flips.length) moves.push({r,c,flips});
}
}
return moves;
}
function setStatus(text, isGameOver=false){
statusEl.textContent = text;
statusEl.classList.toggle('gameover', isGameOver);
}
function maybeAutoPassOrEnd(){
const movesCurrent = legalMoves(current);
if (movesCurrent.length > 0) return;
const other = -current;
const movesOther = legalMoves(other);
if (movesOther.length === 0){
gameOver = true;
const {b,w} = countDiscs();
let result = 'Draw.';
if (b > w) result = 'Black wins!';
else if (w > b) result = 'White wins!';
setStatus(`Game over. ${result} Final score — Black: ${b}, White: ${w}`, true);
} else {
// pass
current = other;
setStatus(`${discName(-current)} has no legal moves. Turn passes to ${discName(current)}.`);
}
}
function discName(player){ return player === BLACK ? 'Black' : 'White'; }
function placeMove(r,c){
if (gameOver) return;
const flips = flipsForMove(r,c,current);
if (!flips.length) return; // invalid move
// apply
board[r][c] = current;
for (const [rr,cc] of flips) board[rr][cc] = current;
// switch turn
current = -current;
// check pass/end
maybeAutoPassOrEnd();
render();
}
function render(){
// update scores
const {b,w} = countDiscs();
scoreBlackEl.textContent = b;
scoreWhiteEl.textContent = w;
// status (if not game over, show turn + move availability)
if (!gameOver){
const moves = legalMoves(current);
if (moves.length){
setStatus(`${discName(current)} to move. Legal moves: ${moves.length}.`);
} else {
// If no moves, maybeAutoPassOrEnd will handle; but keep safe:
setStatus(`${discName(current)} has no legal moves.`);
}
}
// render board
boardEl.innerHTML = '';
const hints = showHints && !gameOver ? new Set(legalMoves(current).map(m => `${m.r},${m.c}`)) : new Set();
for (let r=0;r<SIZE;r++){
for (let c=0;c<SIZE;c++){
const cell = document.createElement('div');
cell.className = 'cell';
cell.dataset.r = r;
cell.dataset.c = c;
const v = board[r][c];
if (v === BLACK || v === WHITE){
const disc = document.createElement('div');
disc.className = `disc ${v === BLACK ? 'black' : 'white'}`;
cell.appendChild(disc);
} else if (hints.has(`${r},${c}`)){
cell.classList.add('hint');
}
cell.addEventListener('click', () => placeMove(r,c));
boardEl.appendChild(cell);
}
}
}
newGameBtn.addEventListener('click', initBoard);
toggleHintsBtn.addEventListener('click', () => {
showHints = !showHints;
toggleHintsBtn.textContent = showHints ? 'Hide Hints' : 'Show Hints';
render();
});
// Start
initBoard();
</script>
</body>
</html>
Comments