How to Build a Multiplayer Chess Demo Without Standing Up a Backend First
Multiplayer prototypes get expensive earlier than they should.
The moment two players need to share state, most teams jump straight to “we need a backend.” That is often true in production. It is not always true for the first playable version of the idea.
This demo starts with a lighter version. It boots a BrowserPod in the host’s browser, copies a Node.js chess server into the pod, installs the dependencies there, and exposes the game through a portal URL. Send that link to another player and both browsers join the same match.
Treat the prototype like a normal Node project
The inner runtime is a regular Node project with a small dependency set:
{
"dependencies": {
"chess.js": "^1.0.0",
"ws": "^8.17.1"
}
}
The prototype needs two things:
- chess rules and move validation
- a WebSocket server for shared state
The demo does not invent a special BrowserPod-only game model. It runs a normal Node server with chess.js and ws.
Boot the pod and copy the full project into /project
The outer app starts by booting BrowserPod, creating a terminal, and wiring the preview iframe to the portal URL:
const pod = await BrowserPod.boot({ apiKey: import.meta.env.VITE_BP_APIKEY });
const terminal = await pod.createDefaultTerminal(
document.querySelector("#console")
);
pod.onPortal(({ url, port }) => {
urlDiv.innerHTML = `Portal available at <a href="${url}">${url}</a> for local server listening on port ${port}`;
portalIframe.src = url;
});
Then it writes the game project into the pod:
await pod.createDirectory("/project", { recursive: true });
await copyFile(pod, "project/main.js");
await copyFile(pod, "project/index.html");
await copyFile(pod, "project/client.js");
await copyFile(pod, "project/styles.css");
await copyFile(pod, "project/package.json");
await pod.createDirectory("/project/assets", { recursive: true });
await copyFile(pod, "project/assets/chessground.min.js");
await copyFile(pod, "project/assets/chessground.base.css");
await copyFile(pod, "project/assets/chessground.brown.css");
await copyFile(pod, "project/assets/chessground.cburnett.css");
The split is straightforward:
- the outer app handles boot, preview, and share flow
- the inner project is the game server and client
Keep the npm cache inside the pod
The demo installs dependencies with an in-project npm cache:
await pod.createDirectory("/project/.npm", { recursive: true });
await pod.run(
"npm",
[
"install",
"--no-audit",
"--no-fund",
"--omit=optional",
"--cache",
"/project/.npm",
],
{
echo: true,
terminal: terminal,
cwd: "/project",
env: ["npm_config_cache=/project/.npm"],
}
);
The cache stays inside the BrowserPod session, alongside the game project, instead of assuming anything about the host machine.
Install time is part of the experience. Keeping the dependency set small and the cache local helps keep startup within a tolerable range.
Start one HTTP server and one WebSocket endpoint
Inside public/project/main.js, the runtime serves the static files over HTTP and opens a WebSocket server on /ws:
const http = require("http");
const WebSocket = require("ws");
const server = http.createServer((req, res) => {
// serve index.html, client.js, styles.css, and assets
});
const wss = new WebSocket.Server({
server,
path: "/ws",
perMessageDeflate: false,
skipUTF8Validation: true,
});
The prototype ends up with one public URL that does two jobs:
- serve the board UI
- carry live game state between connected players
Once the pod server starts listening on port 3000, BrowserPod exposes it through the portal URL and the iframe loads the game.
Keep match state on the server
The runtime keeps the chess state, clocks, seats, and match score on the server side:
const game = new Chess();
const seats = { white: null, black: null };
let status = "waiting";
let winner = null;
const clocks = { white: 10 * 60 * 1000, black: 10 * 60 * 1000 };
const matchScore = { white: 0, black: 0, draws: 0 };
One browser should not be the authority for the game state once two players are connected.
The client connects back to the same host with WebSockets:
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${wsProtocol}://${window.location.host}/ws`;
const socket = new WebSocket(wsUrl);
From there, the runtime can assign white and black seats, allow spectators, and broadcast moves and status updates to every connected client.
Use the live URL as the share link
Once the server is live, the portal URL becomes the share URL.
The client UI just uses the current page location:
shareLink.value = window.location.href;
copyLinkBtn.addEventListener("click", async () => {
const url = window.location.href;
await navigator.clipboard.writeText(url);
});
One person opens the game, gets the portal URL, sends it to someone else, and the second player joins the same server. No extra hosting layer is needed between the two browsers.
Where this approach fits
Use this when the hard part is proving the shared interaction, not scaling the infrastructure.
Examples:
- getting the first playable multiplayer prototype in front of teammates
- testing how a turn-based game feels with real players
- sharing a live prototype with a designer or PM
- validating match flow before committing to backend work
If you already know you need durable sessions, account systems, and persistent state, build the backend. If you need a live multiplayer prototype this week, a BrowserPod-hosted Node server can get you there sooner.