Build a Real-Time 2-Player Chess Game

Build a multiplayer chess game with real-time moves using Next.js and Pusher.

Introduction

30 Second Summary

Playing chess online feels like magic. You move a piece, and your friend across the world sees it appear instantly on their screen. That instant sync is powered by real-time communication, a pattern used in multiplayer games, chat apps, and collaborative tools everywhere.

In this project, you will build a 2-player chess game that two people can play together in real time from different devices. You will create the chessboard UI, enforce the rules of chess, wire up real-time move syncing, and deploy the finished app to a live URL anyone can visit.

What You'll Build

A real-time multiplayer chess game built with Next.js and deployed on Vercel, where moves sync instantly between two browsers using Pusher Channels.

By the end of this project, you'll have:

  • A Next.js application with a game creation page and dynamic game rooms that generate shareable links.
  • A chessboard UI built with CSS Grid and Unicode chess pieces, with full rule enforcement powered by chess.js.
  • A real-time multiplayer system using Pusher Channels to broadcast moves between players instantly.
  • A live deployment on Vercel accessible to anyone with the link.
  • Secret Mission: Add a move history panel that displays all moves in standard chess notation.

Are there any prerequisites?

You need a GitHub account, VS Code installed, and Node.js v20.9 or higher on your machine. No prior experience with Next.js or real-time tools is required.

Set Up Your Project

Before you can build anything, you need a working development environment with the right tools connected. Your chess game relies on three core services: Next.js for the app itself, Pusher for real-time communication, and Vercel for deployment.

In this step, you will scaffold a Next.js project, install all the packages the game needs, and wire up accounts for Pusher and Vercel. By the end, you will have a running dev server and all your credentials stored safely in environment variables.

In this step, get ready to:
  • Create the Next.js app and install all dependencies.
  • Create your Pusher and Vercel accounts.
  • Initialize a GitHub repository and push your first commit.
Create the Next.js app and install dependencies

Next.js is a React framework that handles routing, server-side rendering, and API routes out of the box. You will use its built-in scaffolding tool to generate your project structure.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by clicking Terminal in the top menu bar, then New Terminal.

First, confirm your Node.js version meets the minimum requirement.

  • Check your Node.js version by running this command:
node --version

✔️ I see v20.9 or higher

You are good to go. Move on to creating the project below.

ⓧ I see a version lower than v20.9

Next.js requires Node.js 20.9 or higher. Visit the Node.js download page and install the latest LTS version. After installing, close and reopen your terminal, then run node --version again.

ⓧ Command not found

Node.js is not installed on your machine. Visit the Node.js download page and install the latest LTS version. After installing, close and reopen your terminal, then run node --version again.

Now move to your Desktop so the project folder is easy to find.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Next.js project by running this command:
npx create-next-app@latest chess-game

What will this command do?

This downloads the latest Next.js scaffolding tool and generates a new project folder called chess-game. It will ask you a series of questions to configure the project.

The CLI will launch an interactive wizard. Answer each prompt as follows:

  • When you see Would you like to use the recommended Next.js defaults? select No, customize settings.
  • Would you like to use TypeScript? Select No.
  • Which linter would you like to use? Select ESLint.
  • Would you like to use React Compiler? Select No.
  • Would you like to use Tailwind CSS? Select No.
  • Would you like your code inside a src/ directory? Select No.
  • Would you like to use App Router? Select Yes.
  • Would you like to customize the import alias? Select No.
  • Would you like to include AGENTS.md? Select No.

The tool will create your project and install its base dependencies. This may take a minute.

  • Move into your new project folder by running:
cd chess-game

Now install the three packages the chess game needs.

  • Install the chess logic library and Pusher packages by running:
npm install chess.js@1.4.0 pusher@5.3.3 pusher-js@8.5.0

What are these packages?

chess.js handles all the chess rules: legal moves, check detection, checkmate, and draws.

pusher is the server-side library for broadcasting events. pusher-js is the client-side library that listens for those events in the browser.

Install failing?

Make sure you are inside the chess-game folder (not one level up on the Desktop). Run pwd to confirm your current directory ends with /chess-game.

help me fix an npm install error in my chess-game project

Next, clean up the default layout so the project metadata matches your game.

  • Open app/layout.js in the VS Code Explorer sidebar and replace its entire contents with this code:
import './globals.css';

export const metadata = {
  title: 'Chess - 2 Player',
  description: 'Real-time 2-player chess game',
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

What does this code do?

This is the root layout that wraps every page in your app. The metadata object sets the browser tab title and description. The stripped-down <body> removes all default Next.js styling so you can start fresh.

  • Save app/layout.js.
  • Start the development server by running:
npm run dev
  • Open your browser and visit http://localhost:3000.

You should see the default Next.js welcome page. The browser tab title should now say Chess - 2 Player.

Page not loading?

Check your terminal for error messages. If the port is already in use, stop any other dev server running on port 3000 and try again.

help me debug why localhost:3000 is not loading my Next.js app

Create your Pusher and Vercel accounts

Pusher Channels is a hosted real-time messaging service. It lets your server broadcast events (like chess moves) to connected browsers instantly.

  • Open https://pusher.com in your browser and sign up for a free account.
  • Once you are in the Pusher Dashboard, click Create app under Channels.
  • Name the app chess-game.
  • Select a cluster (for example, us2 or eu). Note which cluster you choose.
  • Click Create app.
  • Go to the App Keys section in the sidebar.
  • Copy the values for app_id, key, secret, and cluster. You will need these in the next step.

What is the Sandbox plan?

Pusher's free Sandbox plan includes 200,000 messages per day and 100 concurrent connections. A chess game sends tiny payloads, so this is more than enough. No credit card is required.

Now store those credentials as environment variables so your app can use them.

  • In VS Code, create a new file called .env.local in the root of your chess-game folder (the same level as package.json).
  • Paste the following into .env.local, replacing each placeholder with the real value you copied from Pusher:
NEXT_PUBLIC_PUSHER_KEY=your-key-here
NEXT_PUBLIC_PUSHER_CLUSTER=your-cluster-here
PUSHER_APP_ID=your-app-id-here
PUSHER_SECRET=your-secret-here

Why are some variables prefixed with NEXT_PUBLIC?

Variables starting with NEXT_PUBLIC_ are exposed to the browser. The key and cluster need to be visible on the client so the browser can connect to Pusher.

The PUSHER_APP_ID and PUSHER_SECRET do NOT have this prefix because they must stay server-side only (in your API route).

  • Save .env.local.

Finally, create your Vercel account for deployment later.

  • Open https://vercel.com in your browser.
  • Click Sign Up and choose Continue with GitHub.
  • Select the free Hobby plan. No credit card is needed.

What is Vercel?

Vercel is the company behind Next.js. Their platform deploys your app to a global CDN with a single push to GitHub. The Hobby plan is free for personal projects and includes 100 deployments per day.

Initialize a GitHub repository

Connecting your project to GitHub lets Vercel auto-deploy whenever you push code. It also gives you version control for your work.

  • Open https://github.com/new in your browser.
  • Name the repository chess-game.
  • Leave it set to Public.
  • Do NOT check any boxes (no README, no .gitignore, no license).
  • Click Create repository.

Now link your local project to this remote repository.

  • Stop the dev server by pressing Ctrl+C in your terminal.
  • Push your code to GitHub by running these commands:
git add .
git commit -m "Initial setup"
git remote add origin https://github.com/[[GITHUB_USERNAME="YOUR-USERNAME"]]/chess-game.git
git branch -M main
git push -u origin main

What do these commands do?

git add . stages all files in the project. git commit saves a snapshot of your current code.

git remote add origin tells Git where to push your code. git push -u origin main uploads everything to GitHub.

  • Refresh the GitHub repository page in your browser. You should see your project files listed.

Push rejected or authentication error?

Make sure you replaced the username placeholder in the remote URL with your actual GitHub username. If you are prompted for a password, GitHub requires a Personal Access Token instead. Generate one at Settings > Developer settings > Personal access tokens.

help me push my chess-game project to GitHub

✔️ Awesome, I've got everything!

Great. Double check that you have saved both app/layout.js and .env.local.

ⓧ I'd like to double check the full code

Here is what app/layout.js should look like:

import './globals.css';

export const metadata = {
  title: 'Chess - 2 Player',
  description: 'Real-time 2-player chess game',
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

Here is what .env.local should look like (with your own Pusher values):

NEXT_PUBLIC_PUSHER_KEY=your-key-here
NEXT_PUBLIC_PUSHER_CLUSTER=your-cluster-here
PUSHER_APP_ID=your-app-id-here
PUSHER_SECRET=your-secret-here

Your environment is ready and connected to all three services. Next up, you will build the chessboard UI and see pieces on screen.

Build the Chessboard UI

Your project environment is set up and your dev server is running. Now it's time to build the visual heart of your chess game.

In this step, you'll create two pages: a landing page where players start a new game, and a game page that renders a full chessboard with pieces. The chessboard uses CSS Grid for layout and Unicode characters for the pieces.

In this step, get ready to:
  • Create a landing page with a New Game button that generates a unique game link.
  • Build a dynamic game page that renders an 8x8 chessboard with all pieces in starting position.
  • Style the board with a dark theme, alternating square colors, and visual indicators.
Create the landing page

Every multiplayer game needs an entry point. Your landing page will have a single button that generates a random game ID and navigates the player to their game room.

  • In VS Code, open app/page.js from the Explorer sidebar.
  • Select all the existing code and replace it with this:
'use client';

import { useRouter } from 'next/navigation';

export default function Home() {
  // Access Next.js router for client-side navigation
  const router = useRouter();

  function createGame() {
    // Generate a random 6-character ID for the game room
    const gameId = Math.random().toString(36).substring(2, 8);
    router.push(`/game/${gameId}?player=white`);
  }

  return (
    <div className="home">
      <h1>Chess</h1>
      <p>Play chess with a friend in real time.</p>
      <button onClick={createGame}>New Game</button>
    </div>
  );
}

What does this code do?

This is a React client component (marked with 'use client') that renders a simple home page.

  • useRouter from Next.js lets you navigate programmatically without a full page reload.
  • createGame() generates a random 6-character string as a unique game ID.
  • The ?player=white query parameter tells the game page which color this player is.
  • Save app/page.js.
  • Switch to your browser and visit http://localhost:3000. You should see a dark page with a "Chess" heading and a blue "New Game" button.

Still seeing the default Next.js page?

Make sure you saved the file after pasting the new code. If your dev server stopped, restart it by running npm run dev in your terminal.

Help me debug why my landing page isn't showing

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

'use client';

import { useRouter } from 'next/navigation';

export default function Home() {
  const router = useRouter();

  function createGame() {
    const gameId = Math.random().toString(36).substring(2, 8);
    router.push(`/game/${gameId}?player=white`);
  }

  return (
    <div className="home">
      <h1>Chess</h1>
      <p>Play chess with a friend in real time.</p>
      <button onClick={createGame}>New Game</button>
    </div>
  );
}
Create the game page with a chessboard

When a player clicks New Game, they navigate to a URL like /game/abc123?player=white. You need a page that matches that URL pattern and renders a chessboard.

Next.js uses folder names wrapped in square brackets to create dynamic routes. A folder named [id] matches any value in that URL segment.

  • In the Explorer sidebar, right-click the app folder and select New Folder.
  • Name the folder game.
  • Right-click the game folder and create another folder inside it named [id] (include the square brackets).
  • Right-click the [id] folder and select New File.
  • Name the file page.js.

Why the square brackets in the folder name?

Next.js treats [id] as a dynamic segment. Any URL like /game/abc123 or /game/xyz789 will match this route, and you can read the actual value inside your component using useParams().

  • Paste the following code into app/game/[id]/page.js:
'use client';

import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { Chess } from 'chess.js';
import Pusher from 'pusher-js';

// Map each piece to its Unicode chess character
const PIECE_SYMBOLS = {
  wk: '\u2654', wq: '\u2655', wr: '\u2656', wb: '\u2657', wn: '\u2658', wp: '\u2659',
  bk: '\u265A', bq: '\u265B', br: '\u265C', bb: '\u265D', bn: '\u265E', bp: '\u265F',
};

export default function GamePage() {
  // Read the game ID from the URL path
  const params = useParams();
  const gameId = params.id;
  // Read the player color from the query string
  const searchParams = useSearchParams();
  const playerColor = searchParams.get('player') === 'white' ? 'w' : 'b';

  // Game state: chess instance, selected square, legal moves, and status text
  const [game, setGame] = useState(new Chess());
  const [selectedSquare, setSelectedSquare] = useState(null);
  const [legalMoves, setLegalMoves] = useState([]);
  const [status, setStatus] = useState('');

  // Pusher and game logic to be added in later steps

  function getBoard() {
    // Get the 2D array of pieces from chess.js
    const board = game.board();
    // Flip the board for the black player so their pieces are at the bottom
    if (playerColor === 'b') {
      return board.map((row) => [...row].reverse()).reverse();
    }
    return board;
  }

  function getSquareName(rowIndex, colIndex) {
    // Convert grid coordinates to algebraic notation (e.g. "e4")
    if (playerColor === 'b') {
      const file = String.fromCharCode(104 - colIndex);
      const rank = rowIndex + 1;
      return `${file}${rank}`;
    }
    const file = String.fromCharCode(97 + colIndex);
    const rank = 8 - rowIndex;
    return `${file}${rank}`;
  }

  function getSquareClass(rowIndex, colIndex) {
    // Determine if the square is light or dark, selected, or a legal move target
    const isLight = (rowIndex + colIndex) % 2 === 0;
    const square = getSquareName(rowIndex, colIndex);
    let className = `square ${isLight ? 'light' : 'dark'}`;
    if (square === selectedSquare) className += ' selected';
    if (legalMoves.includes(square)) className += ' legal-move';
    return className;
  }

  return (
    <div className="game-container">
      <h1>Chess Game</h1>
      <p className="status">{status}</p>
      <p className="player-info">
        You are playing as: {playerColor === 'w' ? 'White' : 'Black'}
      </p>
      <div className="board">
        {getBoard().map((row, rowIndex) =>
          row.map((piece, colIndex) => (
            <div
              key={`${rowIndex}-${colIndex}`}
              className={getSquareClass(rowIndex, colIndex)}
            >
              {piece && PIECE_SYMBOLS[`${piece.color}${piece.type}`]}
            </div>
          ))
        )}
      </div>
    </div>
  );
}

What does this code do?

This component reads the game ID from the URL and the player color from the query string. It creates a new chess.js instance which holds all the rules and piece positions.

  • PIECE_SYMBOLS maps each piece type to its Unicode character (like ♔ for the white king).
  • getBoard() returns the 2D array of pieces from chess.js, flipping it when the player is black so their pieces appear at the bottom.
  • getSquareName() converts grid coordinates (row 0, col 4) to chess notation (like e8).
  • getSquareClass() assigns alternating colors to squares and adds highlight classes for selected pieces and legal moves.
  • Save app/game/[id]/page.js.
  • Return to your browser at http://localhost:3000 and click the New Game button.
  • You should see a page that says "Chess Game" and "You are playing as: White" with a grid of squares and chess piece characters below.

Seeing a 404 page?

Check your folder structure carefully. The path must be exactly app/game/[id]/page.js with the square brackets included in the folder name. If the folder is named id without brackets, Next.js won't recognize it as a dynamic route.

Help me fix my 404 error on the game page

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

'use client';

import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { Chess } from 'chess.js';
import Pusher from 'pusher-js';

const PIECE_SYMBOLS = {
  wk: '\u2654', wq: '\u2655', wr: '\u2656', wb: '\u2657', wn: '\u2658', wp: '\u2659',
  bk: '\u265A', bq: '\u265B', br: '\u265C', bb: '\u265D', bn: '\u265E', bp: '\u265F',
};

export default function GamePage() {
  const params = useParams();
  const gameId = params.id;
  const searchParams = useSearchParams();
  const playerColor = searchParams.get('player') === 'white' ? 'w' : 'b';

  const [game, setGame] = useState(new Chess());
  const [selectedSquare, setSelectedSquare] = useState(null);
  const [legalMoves, setLegalMoves] = useState([]);
  const [status, setStatus] = useState('');

  // Pusher and game logic to be added in later steps

  function getBoard() {
    const board = game.board();
    if (playerColor === 'b') {
      return board.map((row) => [...row].reverse()).reverse();
    }
    return board;
  }

  function getSquareName(rowIndex, colIndex) {
    if (playerColor === 'b') {
      const file = String.fromCharCode(104 - colIndex);
      const rank = rowIndex + 1;
      return `${file}${rank}`;
    }
    const file = String.fromCharCode(97 + colIndex);
    const rank = 8 - rowIndex;
    return `${file}${rank}`;
  }

  function getSquareClass(rowIndex, colIndex) {
    const isLight = (rowIndex + colIndex) % 2 === 0;
    const square = getSquareName(rowIndex, colIndex);
    let className = `square ${isLight ? 'light' : 'dark'}`;
    if (square === selectedSquare) className += ' selected';
    if (legalMoves.includes(square)) className += ' legal-move';
    return className;
  }

  return (
    <div className="game-container">
      <h1>Chess Game</h1>
      <p className="status">{status}</p>
      <p className="player-info">
        You are playing as: {playerColor === 'w' ? 'White' : 'Black'}
      </p>
      <div className="board">
        {getBoard().map((row, rowIndex) =>
          row.map((piece, colIndex) => (
            <div
              key={`${rowIndex}-${colIndex}`}
              className={getSquareClass(rowIndex, colIndex)}
            >
              {piece && PIECE_SYMBOLS[`${piece.color}${piece.type}`]}
            </div>
          ))
        )}
      </div>
    </div>
  );
}
Style the board with CSS

The chessboard is rendering, but it doesn't look like a board yet. You need CSS to give the squares their classic alternating colors and lay them out in a grid.

  • Open app/globals.css in the Explorer sidebar.
  • Select all the existing CSS and replace it with this:
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: system-ui, -apple-system, sans-serif;
  background: #1a1a2e;
  color: #eee;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
}

/* Landing page styles */
.home {
  text-align: center;
}

.home h1 {
  font-size: 3rem;
  margin-bottom: 1rem;
}

.home p {
  margin-bottom: 2rem;
  color: #aaa;
}

.home button {
  padding: 1rem 2rem;
  font-size: 1.2rem;
  background: #4a90d9;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
}

.home button:hover {
  background: #357abd;
}

/* Game page layout */
.game-container {
  text-align: center;
  padding: 2rem;
}

.game-container h1 {
  margin-bottom: 0.5rem;
}

.status {
  font-size: 1.2rem;
  margin-bottom: 0.5rem;
  color: #4a90d9;
}

.player-info {
  margin-bottom: 1rem;
  color: #aaa;
}

/* Chessboard grid */
.board {
  display: grid;
  grid-template-columns: repeat(8, 60px);
  grid-template-rows: repeat(8, 60px);
  margin: 0 auto;
  border: 3px solid #555;
}

.square {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 2.2rem;
  cursor: pointer;
  user-select: none;
}

/* Alternating square colors */
.square.light {
  background: #f0d9b5;
}

.square.dark {
  background: #b58863;
}

/* Highlight for selected piece */
.square.selected {
  background: #7fc97f;
}

/* Dot indicator for legal move targets */
.square.legal-move {
  position: relative;
}

.square.legal-move::after {
  content: '';
  position: absolute;
  width: 20px;
  height: 20px;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 50%;
}

/* Share link section (used in later steps) */
.share-section {
  margin-top: 1.5rem;
}

.share-section p {
  margin-bottom: 0.5rem;
  color: #aaa;
}

.share-section input {
  width: 100%;
  max-width: 400px;
  padding: 0.5rem;
  font-size: 0.9rem;
  background: #2a2a4a;
  color: #eee;
  border: 1px solid #555;
  border-radius: 4px;
}

What do these styles do?

  • The .board class uses CSS Grid with 8 columns and 8 rows of 60px each, creating the chessboard layout.
  • square.light and square.dark give alternating squares their classic tan and brown colors.
  • square.selected turns a clicked piece's square green so the player knows it's active.
  • square.legal-move::after places a semi-transparent dot on squares where the selected piece can move.
  • Save app/globals.css.
  • Return to your browser at http://localhost:3000 and refresh the page. You should see the landing page with a dark background, white text, and a blue New Game button.
  • Click New Game. You should see a chessboard with alternating tan and brown squares, all 32 chess pieces in their starting positions rendered as Unicode characters.

Board looks like a single column instead of a grid?

Make sure your .board class has display: grid and grid-template-columns: repeat(8, 60px). Without the grid columns, all 64 squares stack vertically.

Help me fix my chessboard grid layout

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: system-ui, -apple-system, sans-serif;
  background: #1a1a2e;
  color: #eee;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
}

.home {
  text-align: center;
}

.home h1 {
  font-size: 3rem;
  margin-bottom: 1rem;
}

.home p {
  margin-bottom: 2rem;
  color: #aaa;
}

.home button {
  padding: 1rem 2rem;
  font-size: 1.2rem;
  background: #4a90d9;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
}

.home button:hover {
  background: #357abd;
}

.game-container {
  text-align: center;
  padding: 2rem;
}

.game-container h1 {
  margin-bottom: 0.5rem;
}

.status {
  font-size: 1.2rem;
  margin-bottom: 0.5rem;
  color: #4a90d9;
}

.player-info {
  margin-bottom: 1rem;
  color: #aaa;
}

.board {
  display: grid;
  grid-template-columns: repeat(8, 60px);
  grid-template-rows: repeat(8, 60px);
  margin: 0 auto;
  border: 3px solid #555;
}

.square {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 2.2rem;
  cursor: pointer;
  user-select: none;
}

.square.light {
  background: #f0d9b5;
}

.square.dark {
  background: #b58863;
}

.square.selected {
  background: #7fc97f;
}

.square.legal-move {
  position: relative;
}

.square.legal-move::after {
  content: '';
  position: absolute;
  width: 20px;
  height: 20px;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 50%;
}

.share-section {
  margin-top: 1.5rem;
}

.share-section p {
  margin-bottom: 0.5rem;
  color: #aaa;
}

.share-section input {
  width: 100%;
  max-width: 400px;
  padding: 0.5rem;
  font-size: 0.9rem;
  background: #2a2a4a;
  color: #eee;
  border: 1px solid #555;
  border-radius: 4px;
}

Your chessboard is rendering beautifully with all the pieces in place. Next up, you'll make the pieces interactive by adding click handling, move validation, and turn enforcement.

Add Chess Game Logic

You have a beautiful chessboard on screen, but clicking on any piece does nothing. The board is purely visual right now.

In this step, you will wire up the game logic that makes the board interactive. Players will be able to select pieces, see legal moves, and execute those moves with full chess rule enforcement.

In this step, get ready to:
  • Implement piece selection and move validation.
  • Add turn enforcement and game state display.
Implement piece selection and move validation

Clicking a square needs to do different things depending on the current state. If no piece is selected, clicking your own piece selects it and shows where it can move. If a piece IS selected, clicking a target square attempts the move.

  • In app/game/[id]/page.js, find the getSquareName function.
  • Add the following handleSquareClick function directly below it (before getSquareClass):
  function handleSquareClick(rowIndex, colIndex) {
    const square = getSquareName(rowIndex, colIndex);

    // Only allow moves when it is this player's turn
    if (game.turn() !== playerColor) return;

    if (selectedSquare) {
      // A piece is already selected - attempt to move it
      const moveAttempt = { from: selectedSquare, to: square, promotion: 'q' };
      const gameCopy = new Chess(game.fen());

      try {
        gameCopy.move(moveAttempt);
        setGame(gameCopy);
        setSelectedSquare(null);
        setLegalMoves([]);
      } catch (e) {
        // Move was illegal - check if clicking a different own piece
        const piece = game.get(square);
        if (piece && piece.color === playerColor) {
          setSelectedSquare(square);
          const moves = game.moves({ square, verbose: true });
          setLegalMoves(moves.map((m) => m.to));
        } else {
          setSelectedSquare(null);
          setLegalMoves([]);
        }
      }
    } else {
      // No piece selected yet - select this piece if it belongs to the player
      const piece = game.get(square);
      if (piece && piece.color === playerColor) {
        setSelectedSquare(square);
        const moves = game.moves({ square, verbose: true });
        setLegalMoves(moves.map((m) => m.to));
      }
    }
  }

What does this code do?

This function handles all click interactions on the board. It branches based on whether a piece is already selected.

  • game.turn() !== playerColor prevents the player from moving when it is not their turn.
  • When no piece is selected, clicking your own piece highlights it and calculates legal moves using game.moves({ square, verbose: true }).
  • When a piece IS selected, clicking a target square creates a move attempt with game.move(). If the move is illegal, chess.js throws an error, which the catch block handles.
  • promotion: 'q' automatically promotes pawns to queens, covering 99% of real games without needing a selection UI.

Now you need to wire this function up to the board squares so clicks actually trigger it.

  • In the same file, scroll down to the return statement and find the <div> that renders each square.
  • Find this line:
            <div
              key={`${rowIndex}-${colIndex}`}
              className={getSquareClass(rowIndex, colIndex)}
            >
  • Add an onClick handler so the line now looks like this:
            <div
              key={`${rowIndex}-${colIndex}`}
              className={getSquareClass(rowIndex, colIndex)}
              onClick={() => handleSquareClick(rowIndex, colIndex)}
            >
  • Save app/game/[id]/page.js.
  • Switch to your browser and refresh the game page. Click on a white pawn. You should see the pawn's square turn green and small dark dots appear on the squares it can move to.

Clicking a piece does nothing?

  • Make sure you added the onClick prop on the square <div>, not on the board wrapper.
  • Check that handleSquareClick is placed inside the GamePage component function, not outside it.
  • If you see dots but clicking them does nothing, check that the try/catch block around gameCopy.move(moveAttempt) matches the code above exactly.

Help me debug why clicking chess pieces isn't selecting them or showing legal moves.

Add turn enforcement and game state

The board now responds to clicks, but players have no idea whose turn it is. The status text at the top needs to update dynamically as the game progresses through turns, checks, and checkmate.

  • In app/game/[id]/page.js, find the comment that says // Pusher and game logic to be added in later steps.
  • Replace that comment with the following useEffect that updates the status message whenever the game state changes:
  useEffect(() => {
    if (game.isCheckmate()) {
      setStatus(`Checkmate! ${game.turn() === 'w' ? 'Black' : 'White'} wins!`);
    } else if (game.isDraw()) {
      setStatus('Draw!');
    } else if (game.inCheck()) {
      setStatus('Check!');
    } else {
      setStatus(game.turn() === 'w' ? "White's turn" : "Black's turn");
    }
  }, [game]);

What does this code do?

This useEffect runs every time the game state changes (the [game] dependency array). It checks the game state in priority order.

  • Checkmate is checked first because it ends the game.
  • Draw covers stalemate, insufficient material, and repetition.
  • Check alerts the player their king is in danger.
  • The default shows whose turn it is next.

The white player also needs a way to invite their opponent. You will add a share link that the white player can copy and send.

  • Find the getSquareClass function. Add the following shareLink constant below it, just above the return statement:
  const shareLink = typeof window !== 'undefined'
    ? `${window.location.origin}/game/${gameId}?player=black`
    : '';
  • Now add the share section to the JSX. Find the closing </div> of the board div (the one with className="board"). Add the following code between that closing tag and the final </div> of the game container:
      {playerColor === 'w' && (
        <div className="share-section">
          <p>Share this link with your opponent:</p>
          <input
            type="text"
            readOnly
            value={shareLink}
            onClick={(e) => e.target.select()}
          />
        </div>
      )}

What does the share link do?

The shareLink builds a URL pointing to the same game room but with ?player=black. The typeof window !== 'undefined' check prevents errors during server-side rendering (where window does not exist).

The share section only shows for the white player since they are the game creator. Clicking the input auto-selects the URL for easy copying.

  • Save app/game/[id]/page.js.
  • Refresh your browser. You should see "White's turn" in blue text at the top. Below the board, a text input shows the share link URL.
  • Click a white pawn, then click a legal move dot. The piece moves, and the status updates to "Black's turn".
  • Try clicking a black piece. Nothing happens because it is not black's turn.

Status not updating after moves?

  • Check that the useEffect has [game] in its dependency array. Without it, the effect only runs once on mount.
  • Make sure the useEffect is placed inside the GamePage component, after the state declarations.
  • If the share link does not appear, confirm the conditional renders with playerColor === 'w' and that your URL has ?player=white.

Help me figure out why my chess game status is not updating after I make a move.

✔️ Awesome, I've got everything!

Great. Double check you have saved app/game/[id]/page.js before moving on.

ⓧ I'd like to double check the full code

'use client';

import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { Chess } from 'chess.js';
import Pusher from 'pusher-js';

const PIECE_SYMBOLS = {
  wk: '\u2654', wq: '\u2655', wr: '\u2656', wb: '\u2657', wn: '\u2658', wp: '\u2659',
  bk: '\u265A', bq: '\u265B', br: '\u265C', bb: '\u265D', bn: '\u265E', bp: '\u265F',
};

export default function GamePage() {
  const params = useParams();
  const gameId = params.id;
  const searchParams = useSearchParams();
  const playerColor = searchParams.get('player') === 'white' ? 'w' : 'b';

  const [game, setGame] = useState(new Chess());
  const [selectedSquare, setSelectedSquare] = useState(null);
  const [legalMoves, setLegalMoves] = useState([]);
  const [status, setStatus] = useState('');

  useEffect(() => {
    if (game.isCheckmate()) {
      setStatus(`Checkmate! ${game.turn() === 'w' ? 'Black' : 'White'} wins!`);
    } else if (game.isDraw()) {
      setStatus('Draw!');
    } else if (game.inCheck()) {
      setStatus('Check!');
    } else {
      setStatus(game.turn() === 'w' ? "White's turn" : "Black's turn");
    }
  }, [game]);

  function getBoard() {
    const board = game.board();
    if (playerColor === 'b') {
      return board.map((row) => [...row].reverse()).reverse();
    }
    return board;
  }

  function getSquareName(rowIndex, colIndex) {
    if (playerColor === 'b') {
      const file = String.fromCharCode(104 - colIndex);
      const rank = rowIndex + 1;
      return `${file}${rank}`;
    }
    const file = String.fromCharCode(97 + colIndex);
    const rank = 8 - rowIndex;
    return `${file}${rank}`;
  }

  function handleSquareClick(rowIndex, colIndex) {
    const square = getSquareName(rowIndex, colIndex);

    if (game.turn() !== playerColor) return;

    if (selectedSquare) {
      const moveAttempt = { from: selectedSquare, to: square, promotion: 'q' };
      const gameCopy = new Chess(game.fen());

      try {
        gameCopy.move(moveAttempt);
        setGame(gameCopy);
        setSelectedSquare(null);
        setLegalMoves([]);
      } catch (e) {
        const piece = game.get(square);
        if (piece && piece.color === playerColor) {
          setSelectedSquare(square);
          const moves = game.moves({ square, verbose: true });
          setLegalMoves(moves.map((m) => m.to));
        } else {
          setSelectedSquare(null);
          setLegalMoves([]);
        }
      }
    } else {
      const piece = game.get(square);
      if (piece && piece.color === playerColor) {
        setSelectedSquare(square);
        const moves = game.moves({ square, verbose: true });
        setLegalMoves(moves.map((m) => m.to));
      }
    }
  }

  function getSquareClass(rowIndex, colIndex) {
    const isLight = (rowIndex + colIndex) % 2 === 0;
    const square = getSquareName(rowIndex, colIndex);
    let className = `square ${isLight ? 'light' : 'dark'}`;
    if (square === selectedSquare) className += ' selected';
    if (legalMoves.includes(square)) className += ' legal-move';
    return className;
  }

  const shareLink = typeof window !== 'undefined'
    ? `${window.location.origin}/game/${gameId}?player=black`
    : '';

  return (
    <div className="game-container">
      <h1>Chess Game</h1>
      <p className="status">{status}</p>
      <p className="player-info">
        You are playing as: {playerColor === 'w' ? 'White' : 'Black'}
      </p>
      <div className="board">
        {getBoard().map((row, rowIndex) =>
          row.map((piece, colIndex) => (
            <div
              key={`${rowIndex}-${colIndex}`}
              className={getSquareClass(rowIndex, colIndex)}
              onClick={() => handleSquareClick(rowIndex, colIndex)}
            >
              {piece && PIECE_SYMBOLS[`${piece.color}${piece.type}`]}
            </div>
          ))
        )}
      </div>
      {playerColor === 'w' && (
        <div className="share-section">
          <p>Share this link with your opponent:</p>
          <input
            type="text"
            readOnly
            value={shareLink}
            onClick={(e) => e.target.select()}
          />
        </div>
      )}
    </div>
  );
}

Your chess game now has full move logic. You can select pieces, see legal moves, and play through a game. Next up, you will connect two players in real time so moves sync instantly across different browsers.

Wire Up Real-Time Multiplayer with Pusher

Right now, your chess game works perfectly for one player. You can move pieces, see legal moves, and the turn indicator updates. But there is a critical gap: nothing connects two browsers together.

In this step, you will wire up Pusher Channels to broadcast moves between players in real time. You will build a server-side API route that sends move events, and a client-side subscription that listens for them.

In this step, get ready to:
  • Create an API route that broadcasts moves to a Pusher channel.
  • Subscribe to game events so your client receives the opponent's moves.
  • Connect move-making to the API and test real-time sync between two tabs.
Create the API route to broadcast moves

When a player makes a move, that move needs to reach the other player's browser. The client cannot talk directly to Pusher's broadcast service. Instead, your Next.js server acts as the middleman.

Why a server-side API route?

Pusher requires your secret key to trigger events on a channel. That secret must never be exposed to the browser. A Next.js API route runs on the server, keeping the secret safe while still being callable from your frontend code.

  • Create the folder path app/api/move/ inside your chess-game project.
  • Create a new file called route.js inside that folder.
  • Add the following code to app/api/move/route.js:
import Pusher from 'pusher';

// Initialize the server-side Pusher client with credentials from environment variables
const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY,
  secret: process.env.PUSHER_SECRET,
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER,
  useTLS: true,
});

// POST handler that broadcasts a move to the game's Pusher channel
export async function POST(request) {
  const { gameId, move, player } = await request.json();
  await pusher.trigger(`game-${gameId}`, 'move-made', { move, player });
  return Response.json({ success: true });
}

What does this code do?

  • import Pusher from 'pusher' imports the server-side Pusher SDK (different from pusher-js which is the client-side SDK).
  • The pusher instance reads your secret credentials from .env.local. Notice that PUSHER_APP_ID and PUSHER_SECRET have no NEXT_PUBLIC_ prefix, so they stay server-side only.
  • pusher.trigger() sends a move-made event to the channel named game-{gameId}. Every browser subscribed to that channel will receive the event.
  • Save app/api/move/route.js.

You will not see a visible change yet. The API route is ready to receive requests, but nothing is calling it. The next two substeps will connect everything.

Seeing an error about 'pusher' module not found?

Make sure you installed the server-side package earlier with npm install pusher (not just pusher-js). Check your package.json for both pusher and pusher-js in the dependencies.

Help me debug a module not found error in my Next.js API route

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

import Pusher from 'pusher';

const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY,
  secret: process.env.PUSHER_SECRET,
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER,
  useTLS: true,
});

export async function POST(request) {
  const { gameId, move, player } = await request.json();
  await pusher.trigger(`game-${gameId}`, 'move-made', { move, player });
  return Response.json({ success: true });
}
Subscribe to game events on the client

Your server can now broadcast moves. The next piece is making each player's browser listen for those broadcasts. You will add a Pusher subscription that fires every time the opponent moves.

  • In app/game/[id]/page.js, find the line that says:
  const [status, setStatus] = useState('');
  • Add the following useEffect block directly below that line (above the existing useEffect that handles game status):
  useEffect(() => {
    // Connect to Pusher using the public credentials
    const pusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY, {
      cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER,
    });

    // Subscribe to this game's unique channel
    const channel = pusher.subscribe(`game-${gameId}`);

    // Listen for move events from the other player
    channel.bind('move-made', (data) => {
      // Skip events from our own moves
      if (data.player === playerColor) return;
      setGame((prevGame) => {
        const updatedGame = new Chess(prevGame.fen());
        try {
          updatedGame.move(data.move);
        } catch (e) {
          return prevGame;
        }
        return updatedGame;
      });
    });

    // Clean up on unmount to avoid memory leaks
    return () => {
      channel.unbind_all();
      pusher.unsubscribe(`game-${gameId}`);
      pusher.disconnect();
    };
  }, [gameId, playerColor]);

What does this code do?

  • new Pusher() creates a client-side connection using your public key and cluster. These are safe to expose to the browser because they can only subscribe to channels, not trigger events.
  • pusher.subscribe(`game-${gameId}`) joins a channel unique to this game room. Only players in the same game hear the same events.
  • The if (data.player === playerColor) return check is critical. When you make a move, Pusher broadcasts it back to everyone, including you. This line skips your own moves so you do not apply them twice.
  • The cleanup function disconnects from Pusher when you leave the page, preventing memory leaks.
  • Save app/game/[id]/page.js.

You will not see a change in the browser yet. The subscription is active, but nothing is triggering events to the channel. The next substep completes the loop.

Seeing a console error about Pusher connection?

Open your browser's developer console. If you see a connection refused or invalid key error, double-check that your .env.local file has the correct NEXT_PUBLIC_PUSHER_KEY and NEXT_PUBLIC_PUSHER_CLUSTER values from your Pusher dashboard. After changing environment variables, restart your dev server.

Help me fix my Pusher connection error

Connect move-making to the API

The final piece is sending each successful move to your API route. When a player moves a piece locally, the game should also POST that move to the server so Pusher can broadcast it to the opponent.

  • In app/game/[id]/page.js, find the handleSquareClick function.
  • Inside the try block, find these lines:
      try {
        gameCopy.move(moveAttempt);
        setGame(gameCopy);
        setSelectedSquare(null);
        setLegalMoves([]);
      } catch (e) {
  • Add a fetch call after setLegalMoves([]) so the try block now looks like this:
      try {
        gameCopy.move(moveAttempt);
        setGame(gameCopy);
        setSelectedSquare(null);
        setLegalMoves([]);

        // Send the move to the server so Pusher broadcasts it to the opponent
        fetch('/api/move', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ gameId, move: moveAttempt, player: playerColor }),
        });
      } catch (e) {

What does this code do?

  • After the move succeeds locally (the piece moves on your screen), fetch('/api/move', ...) sends the move details to your server.
  • The body includes the gameId (so Pusher knows which channel to use), the moveAttempt object (with from/to squares), and playerColor (so the receiver can skip their own events).
  • The fetch is not awaited. The local move has already happened, so the player does not need to wait for the network round-trip. The move feels instant.
  • Save app/game/[id]/page.js.
  • Return to your browser at http://localhost:3000 and click New Game.
  • Copy the share link from the bottom of the page.
  • Open a new browser tab and paste the share link into the address bar.
  • In the first tab (white), click a pawn and then click a target square to make a move.
  • Switch to the second tab (black). You should see white's move appear on the board instantly.
  • In the second tab, make a move as black. Switch back to the first tab and confirm the black move appeared.

Moves not syncing between tabs?

Open the browser developer console (F12) in both tabs and check for errors. Common causes:

  • A 500 error on /api/move means your server-side environment variables are wrong. Check that PUSHER_APP_ID and PUSHER_SECRET are set correctly in .env.local and restart your dev server.
  • If you see moves in the console but the board does not update, make sure the Pusher subscription useEffect is placed before the status useEffect in your file.
  • If both tabs show the same player color, make sure the share link includes ?player=black at the end.

Help me debug why moves aren't syncing between my two browser tabs

✔️ Awesome, I've got everything!

Great. Confirm you have saved the file and that moves sync between your two tabs.

ⓧ I'd like to double check the full code

'use client';

import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { Chess } from 'chess.js';
import Pusher from 'pusher-js';

const PIECE_SYMBOLS = {
  wk: '\u2654', wq: '\u2655', wr: '\u2656', wb: '\u2657', wn: '\u2658', wp: '\u2659',
  bk: '\u265A', bq: '\u265B', br: '\u265C', bb: '\u265D', bn: '\u265E', bp: '\u265F',
};

export default function GamePage() {
  const params = useParams();
  const gameId = params.id;
  const searchParams = useSearchParams();
  const playerColor = searchParams.get('player') === 'white' ? 'w' : 'b';

  const [game, setGame] = useState(new Chess());
  const [selectedSquare, setSelectedSquare] = useState(null);
  const [legalMoves, setLegalMoves] = useState([]);
  const [status, setStatus] = useState('');

  useEffect(() => {
    const pusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY, {
      cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER,
    });

    const channel = pusher.subscribe(`game-${gameId}`);

    channel.bind('move-made', (data) => {
      if (data.player === playerColor) return;
      setGame((prevGame) => {
        const updatedGame = new Chess(prevGame.fen());
        try {
          updatedGame.move(data.move);
        } catch (e) {
          return prevGame;
        }
        return updatedGame;
      });
    });

    return () => {
      channel.unbind_all();
      pusher.unsubscribe(`game-${gameId}`);
      pusher.disconnect();
    };
  }, [gameId, playerColor]);

  useEffect(() => {
    if (game.isCheckmate()) {
      setStatus(`Checkmate! ${game.turn() === 'w' ? 'Black' : 'White'} wins!`);
    } else if (game.isDraw()) {
      setStatus('Draw!');
    } else if (game.inCheck()) {
      setStatus('Check!');
    } else {
      setStatus(game.turn() === 'w' ? "White's turn" : "Black's turn");
    }
  }, [game]);

  function getBoard() {
    const board = game.board();
    if (playerColor === 'b') {
      return board.map((row) => [...row].reverse()).reverse();
    }
    return board;
  }

  function getSquareName(rowIndex, colIndex) {
    if (playerColor === 'b') {
      const file = String.fromCharCode(104 - colIndex);
      const rank = rowIndex + 1;
      return `${file}${rank}`;
    }
    const file = String.fromCharCode(97 + colIndex);
    const rank = 8 - rowIndex;
    return `${file}${rank}`;
  }

  function handleSquareClick(rowIndex, colIndex) {
    const square = getSquareName(rowIndex, colIndex);

    if (game.turn() !== playerColor) return;

    if (selectedSquare) {
      const moveAttempt = { from: selectedSquare, to: square, promotion: 'q' };
      const gameCopy = new Chess(game.fen());

      try {
        gameCopy.move(moveAttempt);
        setGame(gameCopy);
        setSelectedSquare(null);
        setLegalMoves([]);

        fetch('/api/move', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ gameId, move: moveAttempt, player: playerColor }),
        });
      } catch (e) {
        const piece = game.get(square);
        if (piece && piece.color === playerColor) {
          setSelectedSquare(square);
          const moves = game.moves({ square, verbose: true });
          setLegalMoves(moves.map((m) => m.to));
        } else {
          setSelectedSquare(null);
          setLegalMoves([]);
        }
      }
    } else {
      const piece = game.get(square);
      if (piece && piece.color === playerColor) {
        setSelectedSquare(square);
        const moves = game.moves({ square, verbose: true });
        setLegalMoves(moves.map((m) => m.to));
      }
    }
  }

  function getSquareClass(rowIndex, colIndex) {
    const isLight = (rowIndex + colIndex) % 2 === 0;
    const square = getSquareName(rowIndex, colIndex);
    let className = `square ${isLight ? 'light' : 'dark'}`;
    if (square === selectedSquare) className += ' selected';
    if (legalMoves.includes(square)) className += ' legal-move';
    return className;
  }

  const shareLink = typeof window !== 'undefined'
    ? `${window.location.origin}/game/${gameId}?player=black`
    : '';

  return (
    <div className="game-container">
      <h1>Chess Game</h1>
      <p className="status">{status}</p>
      <p className="player-info">
        You are playing as: {playerColor === 'w' ? 'White' : 'Black'}
      </p>
      <div className="board">
        {getBoard().map((row, rowIndex) =>
          row.map((piece, colIndex) => (
            <div
              key={`${rowIndex}-${colIndex}`}
              className={getSquareClass(rowIndex, colIndex)}
              onClick={() => handleSquareClick(rowIndex, colIndex)}
            >
              {piece && PIECE_SYMBOLS[`${piece.color}${piece.type}`]}
            </div>
          ))
        )}
      </div>
      {playerColor === 'w' && (
        <div className="share-section">
          <p>Share this link with your opponent:</p>
          <input
            type="text"
            readOnly
            value={shareLink}
            onClick={(e) => e.target.select()}
          />
        </div>
      )}
    </div>
  );
}

Your chess game is now fully playable between two people in real time. Next up, you will deploy it to a live URL so anyone in the world can challenge a friend.

Deploy to Vercel

Your chess game works perfectly on your machine, but right now nobody else can play it. Deploying to Vercel puts your app on a public URL that anyone in the world can visit.

Vercel connects directly to your GitHub repository. Every time you push code, Vercel automatically rebuilds and redeploys your app. In this step, you will push your final code to GitHub and deploy it live.

In this step, get ready to:
  • Push your completed code to GitHub.
  • Import the project into Vercel and add your environment variables.
  • Test your live game with a friend on separate devices.
Push your code and connect to Vercel

Before Vercel can deploy your project, it needs access to your latest code on GitHub. You will commit all the work from previous steps and push it to your remote repository.

  • Commit and push all your changes by running these commands:
git add .
git commit -m "Complete chess game"
git push

What do these commands do?

  • git add . stages every file in your project for the next commit.
  • git commit -m "Complete chess game" saves a snapshot of your code with a descriptive message.
  • git push uploads that snapshot to your GitHub repository so Vercel can see it.
  • Confirm the push succeeded by checking for the message that includes your branch name (main).

Seeing a 'rejected' error?

If your push is rejected, it usually means the remote has changes you do not have locally. Run git pull --rebase origin main first, then try git push again.

If you see a credentials error, make sure you are signed in to GitHub in your terminal or have a personal access token configured.

help me push my code to GitHub

Now you will create a new project on Vercel and connect it to your GitHub repository.

  • Open your browser and go to vercel.com.
  • Sign in with your GitHub account.
  • Click the Add New... drop-down button and select Project.
  • In the Import Git Repository section, find your chess-game repository and click Import.

What is Vercel detecting?

Vercel automatically detects that your project uses Next.js and fills in the correct build settings. You do not need to change the Framework Preset, Build Command, or Output Directory.

Before clicking Deploy, you need to add your Pusher credentials as environment variables. Without these, the API route cannot broadcast moves.

  • Expand the Environment Variables section on the project configuration page.
  • Add the first variable. In the Name field enter NEXT_PUBLIC_PUSHER_KEY and in the Value field paste the matching value from your .env.local file.
  • Click Add to save that variable.
  • Repeat for NEXT_PUBLIC_PUSHER_CLUSTER, PUSHER_APP_ID, and PUSHER_SECRET.
  • Once all 4 variables are listed, click Deploy.

Why not just push .env.local to GitHub?

Your .env.local file is listed in .gitignore by default. This keeps your secret credentials out of your public repository. Vercel stores them securely on its servers and injects them at build time and runtime.

Vercel will now build your Next.js app and deploy it. This typically takes 30 to 60 seconds.

  • Wait for the build to finish. You will see a congratulations page with a preview of your deployed site.
  • Copy the production URL that Vercel assigns (it looks like chess-game-yourname.vercel.app).

Build failed?

Check the build logs in the Vercel dashboard for the specific error. The most common issue is a missing environment variable. Go to your project's Settings then Environment Variables and confirm all 4 values are present and correctly spelled.

If you see a linting error, check that your code matches what you built in previous steps.

help me fix my Vercel deployment error

Test your live game

Your chess game is now live on the internet. Time to prove the real-time multiplayer works across different devices.

  • Open your production URL in your browser.
  • Click New Game.
  • Copy the share link displayed below the board.
  • Send that link to a friend, or open it in a different browser (or an incognito window) on your device.
  • Make a move as white in the first browser.
  • Confirm the move appears instantly on the black player's board in the second browser.
  • Make a move as black and confirm it appears on white's board.

What should I see?

Both browsers display the same board state in real time. White sees the board with white pieces at the bottom. Black sees it flipped with black pieces at the bottom. Moves from either side appear on the other screen within a second.

Moves not syncing on the live site?

Open the browser console (F12) and check for errors. If you see a connection error mentioning Pusher, your NEXT_PUBLIC_PUSHER_KEY or NEXT_PUBLIC_PUSHER_CLUSTER variables may be incorrect in Vercel.

Remember that variables prefixed with NEXT_PUBLIC_ are exposed to the browser, while PUSHER_APP_ID and PUSHER_SECRET stay server-side. All 4 must match your Pusher dashboard values exactly.

help me debug real-time sync on my deployed chess game

Your chess game is live and playable by anyone with the link. You have built a full-stack real-time web app from scratch and deployed it to production. Next up is an optional Secret Mission to add a move history panel that tracks every move in standard chess notation.

Secret mission

Add a Move History Panel

Your chess game works great, but players have no way to review what happened earlier in the match. Can you add a move history panel that displays all moves in standard chess notation, auto-scrolls to the latest move, and updates in real-time for both players?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project uses the Vercel Hobby plan (free) and the Pusher Sandbox plan (free), so there are no ongoing costs.

Resources you used:

  • Local project folder: chess-game on your Desktop.
  • GitHub repository: chess-game.
  • Vercel project: deployed chess game app.
  • Pusher Channels app: chess-game.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep your live chess game accessible.

  • Your Vercel deployment stays live on the free Hobby plan at no cost.
  • Your Pusher app remains active on the free Sandbox plan with no charges.
  • Your GitHub repository stays available for future updates.
  • Your local chess-game folder stays on your Desktop.

Pause - I'll come back to this later

Shut down any running processes to free up system resources. Your deployed app and accounts stay intact.

  • If your dev server is still running, press Ctrl+C in the terminal to stop it.
  • Your Vercel deployment continues to serve your app for free.
  • Your Pusher and GitHub accounts remain active at no cost.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Delete the local project folder by running this command in your terminal:
cd ~/Desktop
rm -rf chess-game

On Windows, run this instead:

cd ~\Desktop
rmdir /s /q chess-game
  • Delete the Vercel project: go to your project in the Vercel dashboard, click Settings, scroll to the bottom of the General page, and click Delete Project.
  • Delete the Pusher app: go to your app in the Pusher dashboard, click App Settings, scroll to the bottom, and click Delete App.
  • Delete the GitHub repository: go to your chess-game repository on GitHub, click Settings, scroll to the Danger Zone section, and click Delete this repository.

That's a Wrap!

That's a Wrap!

Nice work! You just built and deployed a real-time 2-player chess game that anyone can play from a shareable link.

You've learned how to:

  • Built a complete Next.js application with a CSS Grid chessboard UI, full game logic powered by chess.js, and a server-side API route to handle move broadcasting.
  • Implemented real-time multiplayer using Pusher Channels so moves sync instantly between two browsers using the pub/sub messaging pattern.
  • Deployed a live web application to Vercel connected to a GitHub repository, giving you a public URL that auto-deploys on every push.
  • Secret Mission: Added a move history panel displaying all moves in standard algebraic notation with auto-scrolling.

Ready to quiz yourself?