I wanted to play chess with my friends online — but not just chess. I wanted to see their face when I take their queen.
So I built NextBuild Chess — a free, real-time multiplayer chess game with built-in video calling. No sign-up, no downloads. Just share a link and play.
Here's how I built it, and what I learned along the way.
The Tech Stack
- Next.js 14 — App Router, but with a custom server (more on that below)
- Socket.IO — Real-time move relay and player coordination
- WebRTC — Peer-to-peer video calling between players
- js-chess-engine — Move validation, check/checkmate detection, AI opponent
- Howler.js — Sound effects for moves, checks, and game events
- Tailwind CSS — Styling everything
Why a Custom Next.js Server?
This was one of the first decisions I had to make. Normally, you just run next dev and you're done. But Socket.IO needs direct access to the HTTP server — it can't work through Next.js API routes alone.
So I created a server.js that wraps Next.js:
import { createServer } from "node:http";
import next from "next";
import { Server } from "socket.io";
const dev = process.env.NODE_ENV !== "production";
const hostname = "localhost";
const port = 3000;
const app = next({ dev, hostname, port });
const handler = app.getRequestHandler();
app.prepare().then(() => {
const httpServer = createServer(handler);
const io = new Server(httpServer);
io.on("connection", (socket) => {
// All game events handled here
});
httpServer.listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`);
});
});
The key insight: createServer(handler) passes all HTTP requests through to Next.js, while Socket.IO handles its own WebSocket upgrade on the same port. One server, two protocols.
Room-Based Multiplayer — No Database Needed
When you click "Play with Friend" on the home page, a random hex room ID is generated:
const playWithFriend = () => {
const uniqueId = 'xxx-xxx-xxx'.replace(/x/g, () => {
return Math.floor(Math.random() * 16).toString(16);
});
window.location.href = `/${uniqueId}`; // e.g., /a3f-7b2-e9c
}
Share that URL, and your friend joins the same room. The server is completely stateless — it just relays messages between players in the same room:
io.on("connection", (socket) => {
socket.on('join-room', (roomId) => {
socket.join(roomId);
socket.data.roomId = roomId;
const online = io.sockets.adapter.rooms.get(roomId)?.size || 0;
socket.emit("welcome", { socket_id: socket.id, online });
});
// Relay moves to the other player in the room
socket.on('on-move', (data) => {
socket.to(socket.data.roomId).emit('on-move', data);
});
// Relay timer state
socket.on('timer-status', (data) => {
socket.to(socket.data.roomId).emit('timer-status', data);
});
// Notify opponent on disconnect
socket.on('disconnect', () => {
if (socket.data.roomId) {
socket.to(socket.data.roomId).emit('leave-game');
}
});
});
socket.to(roomId) broadcasts only to the other player in that room. No game state stored on the server, no database, no auth. The first player to join gets white, the second gets black.
The Chess Engine
I used js-chess-engine for all the hard chess logic — move validation, check detection, checkmate, castling, en passant, pawn promotion. No need to implement those rules from scratch.
One trick: the engine doesn't work in SSR, so I dynamically import it client-side:
useEffect(() => {
const initializeGame = async () => {
const ChessEngine = await import('js-chess-engine');
const savedGame = localStorage.getItem(`chess_${roomId}`);
let gameInit;
if (savedGame) {
const gameData = JSON.parse(savedGame);
// Resume from saved FEN position
gameInit = new ChessEngine.Game(gameData.gameState);
} else {
gameInit = new ChessEngine.Game();
}
setGame(gameInit);
setBoardState(gameInit.exportJson());
};
initializeGame();
}, []);
The engine gives me everything I need in one call:
const state = game.exportJson();
// {
// pieces: { A1: 'R', B1: 'N', ... },
// moves: { E2: ['E3', 'E4'], D1: ['E2', 'F3', ...] },
// turn: 'white',
// check: false,
// checkMate: false
// }
state.moves contains every legal move for the current player — I just check against this when a piece is dropped.
Drag and Drop — HTML5 Native
For the multiplayer game, I used native HTML5 drag-and-drop events. Each piece is a draggable image, and each square is a drop target:
const handleDragStart = (e, boxId, piece) => {
// Only allow dragging your own pieces on your turn
const canDrag = playAs === boardState.turn &&
boardState.turn === getPiecesVariant(piece);
if (canDrag) {
setDraggedPiece(piece);
setDraggedFrom(boxId);
// Show legal move indicators
setMoveSuggestions(boardState.moves[boxId] || []);
e.dataTransfer.effectAllowed = 'move';
e.target.style.opacity = '0.6';
}
};
const handleDragOver = (e, boxId) => {
// Only allow drop on legal squares
if (moveSuggestions.includes(boxId)) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
};
const handleDrop = (e, boxId) => {
e.preventDefault();
if (draggedFrom && moveSuggestions.includes(boxId)) {
setMove({ from: draggedFrom, to: boxId });
}
};
The key UX decision: when you pick up a piece, legal destination squares light up. You can only drop on valid squares. This makes the game feel intuitive even if you don't know all the rules.
When a move is set, a useEffect executes it and broadcasts to the opponent:
useEffect(() => {
if (!game || !move?.from || !move?.to) return;
game.move(move.from, move.to);
setBoardState(game.exportJson());
setMoveHistory(prev => [...prev, { ...move }]);
// Tell the other player
socket.emit("on-move", { data: move });
moveSelfSound(); // Satisfying click sound
}, [move, game]);
Video Calling — WebRTC with Perfect Negotiation
This was the hardest part. WebRTC is powerful but the signaling dance is tricky — especially when both peers try to create offers simultaneously.
I used the Perfect Negotiation pattern:
- Black player = "impolite" peer (always initiates)
- White player = "polite" peer (yields on collision)
The peer service manages the RTCPeerConnection:
class PeerService {
constructor() {
this.peer = new RTCPeerConnection({
iceServers: [{
urls: [
"stun:stun.l.google.com:19302",
"stun:global.stun.twilio.com:3478"
]
}]
});
}
async getOffer() {
this.makingOffer = true;
const offer = await this.peer.createOffer();
await this.peer.setLocalDescription(offer);
this.makingOffer = false;
return offer;
}
async getAnswer(offer) {
await this.peer.setRemoteDescription(offer);
const answer = await this.peer.createAnswer();
await this.peer.setLocalDescription(answer);
return answer;
}
}
The signaling flows through Socket.IO — the same connection we use for game moves:
// Black player initiates
const offer = await peer.getOffer();
socket.emit('call-send', offer);
// White player responds
socket.on('call-recive', async (offer) => {
const answer = await peer.getAnswer(offer);
socket.emit('call-accepted', answer);
});
// Black player completes connection
socket.on('call-accepted', async (answer) => {
await peer.peer.setRemoteDescription(answer);
});
Collision handling on the white (polite) player:
const handleIncomingCall = async (offer) => {
const offerCollision = peer.makingOffer ||
peer.peer?.signalingState !== 'stable';
const isPolite = playAs === 'white';
if (!isPolite && offerCollision) return; // Impolite ignores
if (offerCollision && peer.peer?.signalingState === 'have-local-offer') {
await peer.peer.setLocalDescription({ type: 'rollback' });
}
const answer = await peer.getAnswer(offer);
socket.emit('call-accepted', answer);
};
Sound Design
Small detail, big impact. I used Howler.js for game sounds — move clicks, check alerts, game start/end chimes:
import { Howl } from 'howler';
export const moveSelf = () => {
new Howl({ src: ['/assets/sounds/move-self.mp3'] }).play();
}
export const check = () => {
new Howl({ src: ['/assets/sounds/check.mp3'] }).play();
}
Each sound gets its own Howl instance so they can overlap without cutting each other off. When you hear that check sound, you know something happened.
Game Persistence with localStorage
Page refresh shouldn't end your game. I save the full game state to localStorage keyed by room ID:
const saveGame = useCallback(() => {
const gameData = {
playAs,
player,
opponent,
gameState: game.exportFEN(), // Compact board representation
moveHistory,
timer,
};
localStorage.setItem(`chess_${roomId}`, JSON.stringify(gameData));
}, [roomId, playAs, player, opponent, game, moveHistory, timer]);
FEN (Forsyth-Edwards Notation) encodes the entire board state in a single string — piece positions, whose turn it is, castling rights, en passant targets. When the page reloads, the engine recreates the exact game state from this string.
Play vs AI
For solo practice, there's a "Play with AI" mode using the engine's built-in AI:
const aiResponse = game.aiMove(4); // Difficulty level 1-4
One line. The engine evaluates positions and returns the best move it can find. Level 4 is surprisingly competent.
The Board
The 8x8 grid is rendered with CSS Grid. Each square gets a chess notation ID (A1 through H8):
export function generateBoard(playAs) {
const board = [];
const colors = ['#074a8e', '#fff'];
for (let x = 8; x >= 1; x--) {
for (let y = 1; y <= 8; y++) {
const id = `${String.fromCharCode(64 + y)}${x}`;
const color = colors[(x + y) % 2];
board.push({ id, color });
}
}
if (playAs === 'black') board.reverse();
return board;
}
If you're playing black, the board flips — board.reverse() is all it takes.
What I Learned
Socket.IO + Next.js needs a custom server. There's no way around it if you want WebSocket support on the same port. The trade-off is losing some Next.js optimizations, but it's worth it.
WebRTC is hard. The ICE gathering, STUN/TURN negotiation, and offer/answer dance have a lot of edge cases. The Perfect Negotiation pattern saved me from most of them.
Don't build a chess engine.
js-chess-enginehandles all the rules — castling, en passant, promotion, check, checkmate. I started writing my own move calculator and quickly realized it wasn't worth it.Sound makes everything feel better. Adding move sounds and check alerts took 30 minutes but made the game feel 10x more polished.
Stateless servers scale. With no game state on the server, horizontal scaling is trivial. Each server just relays messages — no shared state to worry about.
Try It
chess.nextbuild.tech — open it, click "Play with Friend", and send the link to someone. Or just click "Play with AI" to try it solo.
No sign-up. No download. Just chess.
Built with Next.js, Socket.IO, WebRTC, and a lot of caffeine. If you found this interesting, drop a comment or a reaction — I'd love to hear what you think.
Top comments (0)